<?php

namespace WPSN\Modules\Audit;

use WPSN\Core\Logger;
use WPSN\Core\Queue\QueueInterface;
use WPSN\Core\Settings;
use WPSN\SEO\SeoMetaRepository;
use WPSN\SEO\MetaKeys;
use WPSN\Modules\Revisions\RevisionsInstaller;
use WPSN\Modules\Revisions\RevisionsRepository;
use WPSN\Modules\Bulk\BulkQueueSessionRepository;
use WPSN\Modules\Bulk\BulkSessionReportRepository;

defined('ABSPATH') || exit;

/**
 * Async Audit scan jobs (Stage 8+):
 * - schedule scan as queue batches
 * - process posts/terms in chunks
 * - finalize (duplicates + system checks) and mark scan completed
 */
final class AuditJobs
{
    /**
     * Dispatcher job that enqueues scan batches in small chunks.
     *
     * This prevents huge "enqueue storms" on large sites which can lead to timeouts/503.
     */
    public const JOB_SCAN_DISPATCH = 'audit_scan_dispatch';
    public const JOB_SCAN_POSTS = 'audit_scan_posts';
    public const JOB_SCAN_TERMS = 'audit_scan_terms';
    public const JOB_SCAN_FINALIZE = 'audit_scan_finalize';
    public const JOB_BULK_FIX = 'audit_bulk_fix';
    public const JOB_REFRESH_OBJECT_ISSUES = 'audit_refresh_object_issues';

    // Smaller default batches to reduce the chance of timeouts/503 (especially on shared hosting).
    // These can be tuned later via settings if needed.
    private const POSTS_PER_BATCH = 20;
    private const TERMS_PER_BATCH = 50;

    /**
     * How many scan jobs to enqueue per dispatch run.
     * Kept small to avoid DB spikes and 503 errors on shared hosting.
     */
    // Keep this low: each dispatch run inserts N rows into the queue table.
    // On some shared hosts, large enqueue bursts can trigger 503.
    private const DISPATCH_ENQUEUE_LIMIT = 15;

    private SeoMetaRepository $metaRepo;
    private AuditIssueRepository $issues;
    private AuditScanRepository $scans;
    private AuditScanIssueRepository $scanIssues;
    private AuditTitleKeyRepository $titleKeys;
    private AuditDescriptionKeyRepository $descKeys;
    private QueueInterface $queue;
    private Settings $settings;
    private Logger $logger;

    public function __construct(
        SeoMetaRepository $metaRepo,
        AuditIssueRepository $issues,
        AuditScanRepository $scans,
        AuditScanIssueRepository $scanIssues,
        AuditTitleKeyRepository $titleKeys,
        AuditDescriptionKeyRepository $descKeys,
        QueueInterface $queue,
        Settings $settings,
        Logger $logger
    ) {
        $this->metaRepo = $metaRepo;
        $this->issues = $issues;
        $this->scans = $scans;
        $this->scanIssues = $scanIssues;
        $this->titleKeys = $titleKeys;
        $this->descKeys = $descKeys;
        $this->queue = $queue;
        $this->settings = $settings;
        $this->logger = $logger;
    }

    /**
     * @return array<int,string>
     */
    public static function fieldsForIssueCode(string $issueCode): array
    {
        $issueCode = sanitize_key((string) $issueCode);

        if (in_array($issueCode, [
            AuditRunner::ISSUE_MISSING_TITLE,
            AuditRunner::ISSUE_DUPLICATE_TITLE,
            AuditRunner::ISSUE_TITLE_TOO_SHORT,
            AuditRunner::ISSUE_TITLE_TOO_LONG,
            AuditRunner::ISSUE_TITLE_TEMPLATE_BROKEN,
        ], true)) {
            return ['title'];
        }

        if (in_array($issueCode, [
            AuditRunner::ISSUE_MISSING_DESCRIPTION,
            AuditRunner::ISSUE_DUPLICATE_DESCRIPTION,
            AuditRunner::ISSUE_DESCRIPTION_TOO_SHORT,
            AuditRunner::ISSUE_DESCRIPTION_TOO_LONG,
            AuditRunner::ISSUE_DESCRIPTION_TEMPLATE_BROKEN,
        ], true)) {
            return ['description'];
        }

        return ['title', 'description'];
    }

    /**
     * @param array<int,string> $issueCodes
     */
    public static function defaultOnlyIfEmptyForIssueCodes(array $issueCodes): bool
    {
        $issueCodes = array_values(array_filter(array_map('sanitize_key', $issueCodes), static fn($v) => $v !== ''));
        if (empty($issueCodes)) {
            return false;
        }

        foreach ($issueCodes as $issueCode) {
            if (strpos((string) $issueCode, 'missing_') !== 0) {
                return false;
            }
        }

        return true;
    }

    /**
     * Schedule a new async scan.
     *
     * Options:
     * - mode: 'normal' | 'light'
     * - posts_per_batch: int
     * - terms_per_batch: int
     * - dispatch_limit: int (how many scan jobs to enqueue per dispatcher run)
     * - dispatch_delay: int (seconds; delay before next dispatcher run)
     *
     * @param array<string,mixed> $options
     * @return array{scan_id:int,total_posts:int,total_terms:int,total_jobs:int,config:array<string,mixed>}
     */
    public function scheduleScan(bool $includeTerms = true, bool $checkSocialImage = true, array $options = []): array
    {
        // Ensure DB tables exist.
        AuditInstaller::maybeInstall();

        $mode = isset($options['mode']) ? sanitize_key((string) $options['mode']) : 'normal';
        if (!in_array($mode, ['normal', 'light'], true)) {
            $mode = 'normal';
        }

        // Default batches are already conservative. Light mode is even smaller
        // to reduce the chance of 503 on shared hosting.
        $postsPerBatch = self::POSTS_PER_BATCH;
        $termsPerBatch = self::TERMS_PER_BATCH;
        $dispatchLimit = self::DISPATCH_ENQUEUE_LIMIT;
        $dispatchDelay = 2;

        if ($mode === 'light') {
            $postsPerBatch = 10;
            $termsPerBatch = 25;
            $dispatchLimit = 5;
            $dispatchDelay = 5;
        }

        if (isset($options['posts_per_batch'])) {
            $postsPerBatch = (int) $options['posts_per_batch'];
        }
        if (isset($options['terms_per_batch'])) {
            $termsPerBatch = (int) $options['terms_per_batch'];
        }
        if (isset($options['dispatch_limit'])) {
            $dispatchLimit = (int) $options['dispatch_limit'];
        }
        if (isset($options['dispatch_delay'])) {
            $dispatchDelay = (int) $options['dispatch_delay'];
        }

        $postsPerBatch = max(1, min(500, $postsPerBatch));
        $termsPerBatch = max(1, min(500, $termsPerBatch));
        $dispatchLimit = max(1, min(200, $dispatchLimit));
        $dispatchDelay = max(1, min(60, $dispatchDelay));

        // Prevent multiple concurrent scans (latest issues table is global).
        $running = $this->scans->latestRunning();
        if (is_array($running) && isset($running['id']) && (int) $running['id'] > 0) {
            return [
                'scan_id' => (int) $running['id'],
                'total_posts' => isset($running['total_posts']) ? (int) $running['total_posts'] : 0,
                'total_terms' => isset($running['total_terms']) ? (int) $running['total_terms'] : 0,
                'total_jobs' => 0,
                'config' => [],
                'already_running' => 1,
            ];
        }

        if (function_exists('wp_raise_memory_limit')) {
            wp_raise_memory_limit('admin');
        }

        // Create history scan record first.
        $scanId = $this->scans->createScan($includeTerms, $checkSocialImage);

        // Keep latest-issues table in sync (fast UI). History is stored separately.
        $this->issues->truncate();

        // Freeze scan config once.
        $config = $this->buildFrozenConfig();
        $config['scan_started_ts'] = time();

        $totalPosts = 0;
        $totalTerms = 0;

        // Instead of enqueueing thousands of jobs in one request (which can trigger timeouts/503
        // on shared hosts), we enqueue a single dispatcher job that will enqueue scan batches
        // gradually.
        $postTypeCounts = [];
        $postTypes = [];

        $publicPostTypes = get_post_types(['public' => true], 'names');
        if (is_array($publicPostTypes)) {
            foreach ($publicPostTypes as $pt) {
                $pt = (string) $pt;
                if ($pt === 'attachment') {
                    continue;
                }

                $countObj = wp_count_posts($pt);
                $count = 0;
                if (is_object($countObj) && isset($countObj->publish)) {
                    $count = (int) $countObj->publish;
                }
                $count = max(0, $count);

                $postTypeCounts[$pt] = $count;
                $totalPosts += $count;

                if ($count > 0) {
                    $postTypes[] = $pt;
                }
            }
        }

        $taxCounts = [];
        $taxonomies = [];

        if ($includeTerms) {
            $publicTaxonomies = get_taxonomies(['public' => true], 'names');
            if (is_array($publicTaxonomies)) {
                foreach ($publicTaxonomies as $tax) {
                    $tax = (string) $tax;
                    if ($tax === 'post_format') {
                        continue;
                    }

                    $termCount = 0;
                    $c = wp_count_terms($tax, ['hide_empty' => false]);
                    if (!is_wp_error($c)) {
                        $termCount = (int) $c;
                    }
                    $termCount = max(0, $termCount);

                    $taxCounts[$tax] = $termCount;
                    $totalTerms += $termCount;

                    if ($termCount > 0) {
                        $taxonomies[] = $tax;
                    }
                }
            }
        }

        // Store dispatcher plan inside config_json (frozen config + counts).
        $config['dispatch'] = [
            'mode' => $mode,
            'posts_per_batch' => $postsPerBatch,
            'terms_per_batch' => $termsPerBatch,
            'dispatch_limit' => $dispatchLimit,
            'dispatch_delay' => $dispatchDelay,
            'post_types' => $postTypes,
            'post_type_counts' => $postTypeCounts,
            'pt_index' => 0,
            'pt_page' => 1,
            'include_terms' => $includeTerms ? 1 : 0,
            'taxonomies' => $taxonomies,
            'taxonomy_counts' => $taxCounts,
            'tax_index' => 0,
            'tax_offset' => 0,
        ];

        // Enqueue dispatcher as the only job for scan start.
        $this->queue->enqueue(self::JOB_SCAN_DISPATCH, [
            '__priority' => 0,
            'scan_id' => $scanId,
            'config' => $config,
            'state' => $config['dispatch'],
            'limit' => $dispatchLimit,
            'delay' => $dispatchDelay,
        ]);

        // Jobs are enqueued gradually, but we can still provide an estimate.
        $totalJobs = 1;
        foreach ($postTypes as $pt) {
            $cnt = isset($postTypeCounts[$pt]) ? (int) $postTypeCounts[$pt] : 0;
            if ($cnt > 0) {
                $totalJobs += (int) ceil($cnt / max(1, (int) $postsPerBatch));
            }
        }
        if ($includeTerms) {
            foreach ($taxonomies as $tax) {
                $cnt = isset($taxCounts[$tax]) ? (int) $taxCounts[$tax] : 0;
                if ($cnt > 0) {
                    $totalJobs += (int) ceil($cnt / max(1, (int) $termsPerBatch));
                }
            }
        }
        // Finalize job.
        $totalJobs++;

        // Store totals + config.
        $this->scans->initAsyncScan($scanId, $totalPosts, $totalTerms, $config);

        $this->logger->info('Audit scan queued', [
            'scan_id' => $scanId,
            'total_posts' => $totalPosts,
            'total_terms' => $totalTerms,
            'total_jobs' => $totalJobs,
        ]);

        return [
            'scan_id' => (int) $scanId,
            'total_posts' => (int) $totalPosts,
            'total_terms' => (int) $totalTerms,
            'total_jobs' => (int) $totalJobs,
            'config' => $config,
        ];
    }

    /**
     * Cancel a running scan.
     *
     * Best-effort:
     * - Mark scan as cancelled in history table.
     * - Delete pending audit jobs for this scan.
     * - Any already running job will stop on the next status check.
     *
     * @return array{scan_id:int,status:string,deleted_jobs:int}
     */
    public function cancelScan(int $scanId, string $reason = ''): array
    {
        $scanId = (int) $scanId;
        if ($scanId <= 0) {
            return ['scan_id' => 0, 'status' => 'invalid', 'deleted_jobs' => 0];
        }

        $scan = $this->scans->get($scanId);
        if (!is_array($scan)) {
            return ['scan_id' => $scanId, 'status' => 'not_found', 'deleted_jobs' => 0];
        }

        $status = isset($scan['status']) ? (string) $scan['status'] : '';
        if ($status !== 'running') {
            return ['scan_id' => $scanId, 'status' => $status !== '' ? $status : 'not_running', 'deleted_jobs' => 0];
        }

        $reason = $reason !== '' ? sanitize_text_field($reason) : __('Cancelled by user', 'wp-seo-next');

        $startedAt = isset($scan['started_at']) ? (string) $scan['started_at'] : '';
        $startedTs = $startedAt !== '' ? strtotime($startedAt) : false;
        if (!$startedTs) {
            $startedTs = time();
        }
        $nowTs = time();

        $counts = $this->scanIssues->countsByCode($scanId);
        $issuesTotal = 0;
        foreach ($counts as $c) {
            $issuesTotal += (int) $c;
        }

        $summary = [
            'duration_sec' => max(0, $nowTs - (int) $startedTs),
            'total_posts' => isset($scan['total_posts']) ? (int) $scan['total_posts'] : 0,
            'total_terms' => isset($scan['total_terms']) ? (int) $scan['total_terms'] : 0,
            'scanned_posts' => isset($scan['scanned_posts']) ? (int) $scan['scanned_posts'] : 0,
            'scanned_terms' => isset($scan['scanned_terms']) ? (int) $scan['scanned_terms'] : 0,
            'news_eligible' => isset($scan['news_eligible']) ? (int) $scan['news_eligible'] : 0,
            'video_candidates' => isset($scan['video_candidates']) ? (int) $scan['video_candidates'] : 0,
            'video_eligible' => isset($scan['video_eligible']) ? (int) $scan['video_eligible'] : 0,
            'issues_total' => $issuesTotal,
            'issues_by_code' => $counts,
        ];

        // Mark cancelled in history. We intentionally do not overwrite `wpsn_audit_last_scan`
        // so "last scan" still reflects the last completed run.
        $this->scans->finishScan($scanId, $summary, 'cancelled', $reason);

        // Best-effort: delete pending audit jobs for this scan.
        $deleted = $this->deletePendingJobsForScan($scanId);

        $this->logger->info('Audit scan cancelled', [
            'scan_id' => $scanId,
            'deleted_jobs' => $deleted,
        ]);

        /**
         * Fired when an audit scan is cancelled by user.
         *
         * @param int $scanId
         * @param array<string,mixed> $summary
         * @param int $deletedJobs
         */
        do_action('wpsn/audit/scan_cancelled', $scanId, $summary, $deleted);

        return [
            'scan_id' => $scanId,
            'status' => 'cancelled',
            'deleted_jobs' => (int) $deleted,
        ];
    }

    private function deletePendingJobsForScan(int $scanId): int
    {
        global $wpdb;

        $scanId = (int) $scanId;
        if ($scanId <= 0) {
            return 0;
        }

        $table = $wpdb->prefix . 'wpsn_queue_jobs';
        $types = [self::JOB_SCAN_DISPATCH, self::JOB_SCAN_POSTS, self::JOB_SCAN_TERMS, self::JOB_SCAN_FINALIZE];
        $placeholders = implode(',', array_fill(0, count($types), '%s'));

        // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
        $sql = $wpdb->prepare(
            "SELECT id, payload FROM {$table} WHERE status = %s AND job_type IN ({$placeholders})",
            array_merge(['pending'], $types)
        );

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $rows = $wpdb->get_results($sql, ARRAY_A);
        if (!is_array($rows) || empty($rows)) {
            return 0;
        }

        $ids = [];
        foreach ($rows as $r) {
            if (!is_array($r) || empty($r['id'])) {
                continue;
            }
            $payload = [];
            if (isset($r['payload'])) {
                $decoded = json_decode((string) $r['payload'], true);
                if (is_array($decoded)) {
                    $payload = $decoded;
                }
            }
            $sid = isset($payload['scan_id']) ? (int) $payload['scan_id'] : 0;
            if ($sid === $scanId) {
                $ids[] = (int) $r['id'];
            }
        }

        $ids = array_values(array_filter($ids, static fn($v) => (int) $v > 0));
        if (empty($ids)) {
            return 0;
        }

        $deleted = 0;
        foreach (array_chunk($ids, 200) as $chunk) {
            $chunk = array_values(array_map('intval', $chunk));
            $ph = implode(',', array_fill(0, count($chunk), '%d'));
            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnquotedComplexPlaceholder
            $delSql = $wpdb->prepare("DELETE FROM {$table} WHERE id IN ({$ph})", $chunk);
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $deleted += (int) $wpdb->query($delSql);
        }

        return max(0, (int) $deleted);
    }

    /**
     * Queue handler: dispatch scan jobs in small chunks.
     *
     * This runs before scan jobs exist and incrementally enqueues scan batches.
     * It is intentionally lightweight to avoid timeouts/503 errors on large sites.
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     * @return mixed
     */
    public function handleDispatch($unused, array $payload, array $job)
    {
        $scanId = isset($payload['scan_id']) ? (int) $payload['scan_id'] : 0;
        if ($scanId <= 0) {
            return null;
        }

        $scan = $this->scans->get($scanId);
        if (!is_array($scan)) {
            return null;
        }
        if ((string) ($scan['status'] ?? '') !== 'running') {
            return null;
        }

        // Mark phase for UI.
        $this->scans->setPhase($scanId, 'dispatch');

        $config = isset($payload['config']) && is_array($payload['config']) ? $payload['config'] : [];
        $state = isset($payload['state']) && is_array($payload['state']) ? $payload['state'] : [];
        if (empty($state) && isset($config['dispatch']) && is_array($config['dispatch'])) {
            $state = $config['dispatch'];
        }

        $limit = isset($payload['limit']) ? (int) $payload['limit'] : self::DISPATCH_ENQUEUE_LIMIT;
        $limit = max(5, min(500, $limit));

        $delay = isset($payload['delay']) ? (int) $payload['delay'] : 0;
        if ($delay < 1 && isset($state['dispatch_delay'])) {
            $delay = (int) $state['dispatch_delay'];
        }
        $delay = max(1, min(60, $delay > 0 ? $delay : 2));

        $postsPerBatch = isset($state['posts_per_batch']) ? (int) $state['posts_per_batch'] : self::POSTS_PER_BATCH;
        $postsPerBatch = max(1, min(500, $postsPerBatch));

        $termsPerBatch = isset($state['terms_per_batch']) ? (int) $state['terms_per_batch'] : self::TERMS_PER_BATCH;
        $termsPerBatch = max(1, min(500, $termsPerBatch));

        $postTypes = isset($state['post_types']) && is_array($state['post_types']) ? array_values($state['post_types']) : [];
        $postTypeCounts = isset($state['post_type_counts']) && is_array($state['post_type_counts']) ? $state['post_type_counts'] : [];
        $ptIndex = isset($state['pt_index']) ? max(0, (int) $state['pt_index']) : 0;
        $ptPage = isset($state['pt_page']) ? max(1, (int) $state['pt_page']) : 1;

        $includeTerms = !empty($state['include_terms']);
        $taxonomies = isset($state['taxonomies']) && is_array($state['taxonomies']) ? array_values($state['taxonomies']) : [];
        $taxCounts = isset($state['taxonomy_counts']) && is_array($state['taxonomy_counts']) ? $state['taxonomy_counts'] : [];
        $taxIndex = isset($state['tax_index']) ? max(0, (int) $state['tax_index']) : 0;
        $taxOffset = isset($state['tax_offset']) ? max(0, (int) $state['tax_offset']) : 0;

        $enqueued = 0;

        // Enqueue post scan batches.
        $ptTotal = count($postTypes);
        while ($enqueued < $limit && $ptIndex < $ptTotal) {
            $pt = isset($postTypes[$ptIndex]) ? sanitize_key((string) $postTypes[$ptIndex]) : '';
            if ($pt === '' || $pt === 'attachment') {
                $ptIndex++;
                $ptPage = 1;
                continue;
            }

            $cnt = isset($postTypeCounts[$pt]) ? (int) $postTypeCounts[$pt] : 0;
            if ($cnt < 1) {
                $ptIndex++;
                $ptPage = 1;
                continue;
            }

            $pages = (int) ceil($cnt / max(1, $postsPerBatch));
            $pages = max(1, $pages);

            if ($ptPage > $pages) {
                $ptIndex++;
                $ptPage = 1;
                continue;
            }

            $this->queue->enqueue(self::JOB_SCAN_POSTS, [
                '__priority' => 1,
                'scan_id' => $scanId,
                'post_type' => $pt,
                'page' => $ptPage,
                'per_page' => $postsPerBatch,
                'config' => $config,
            ]);

            $enqueued++;
            $ptPage++;
        }

        // Enqueue term scan batches.
        if ($includeTerms && $enqueued < $limit) {
            $txTotal = count($taxonomies);
            while ($enqueued < $limit && $taxIndex < $txTotal) {
                $tax = isset($taxonomies[$taxIndex]) ? sanitize_key((string) $taxonomies[$taxIndex]) : '';
                if ($tax === '' || $tax === 'post_format' || !taxonomy_exists($tax)) {
                    $taxIndex++;
                    $taxOffset = 0;
                    continue;
                }

                $cnt = isset($taxCounts[$tax]) ? (int) $taxCounts[$tax] : 0;
                if ($cnt < 1) {
                    $taxIndex++;
                    $taxOffset = 0;
                    continue;
                }

                if ($taxOffset >= $cnt) {
                    $taxIndex++;
                    $taxOffset = 0;
                    continue;
                }

                $this->queue->enqueue(self::JOB_SCAN_TERMS, [
                    '__priority' => 1,
                    'scan_id' => $scanId,
                    'taxonomy' => $tax,
                    'offset' => $taxOffset,
                    'number' => $termsPerBatch,
                    'config' => $config,
                ]);

                $enqueued++;
                $taxOffset += $termsPerBatch;
            }
        }

        $donePosts = $ptIndex >= count($postTypes);
        $doneTerms = !$includeTerms || $taxIndex >= count($taxonomies);

        // If everything is enqueued, schedule finalize.
        if ($donePosts && $doneTerms) {
            $this->queue->enqueue(self::JOB_SCAN_FINALIZE, [
                '__priority' => 5,
                'scan_id' => $scanId,
                'config' => $config,
            ]);

            $this->logger->info('Audit scan dispatch completed', [
                'scan_id' => $scanId,
                'enqueued_now' => $enqueued,
            ]);

            return null;
        }

        // Not done: enqueue next dispatch job with updated cursor (small delay to avoid tight loop).
        $state['posts_per_batch'] = $postsPerBatch;
        $state['terms_per_batch'] = $termsPerBatch;
        $state['dispatch_limit'] = $limit;
        $state['dispatch_delay'] = $delay;
        $state['pt_index'] = $ptIndex;
        $state['pt_page'] = $ptPage;
        $state['tax_index'] = $taxIndex;
        $state['tax_offset'] = $taxOffset;

        $this->queue->enqueue(self::JOB_SCAN_DISPATCH, [
            '__priority' => 0,
            'scan_id' => $scanId,
            'config' => $config,
            'state' => $state,
            'limit' => $limit,
            'delay' => $delay,
        ], time() + $delay);

        return null;
    }

    /**
     * Queue handler: scan post batch.
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     * @return mixed
     */

    /**
     * Schedule bulk actions for objects matching current filters.
     *
     * Supported actions:
     * - generate_local: enqueue Bulk Optimize local suggestions (draft)
     * - generate_apply_local: enqueue Bulk Optimize local suggestions + apply
     * - clear_canonical: remove canonical override (post/term)
     * - clear_noindex: remove robots-index override (post/term)
     *
     * @param array<string,mixed> $filters
     * @param array<string,mixed> $opts
     * @return array{action:string,scan_id:int,targets:int,eligible_targets:int,queued_jobs:int,skipped:int,session_id:string}
     */
    public function scheduleBulkAction(int $scanId, array $filters, string $action, array $opts = []): array
    {
        $scanId = (int) $scanId;

        $action = sanitize_key((string) $action);
        $allowed = ['generate_local', 'generate_apply_local', 'clear_canonical', 'clear_noindex'];
        if (!in_array($action, $allowed, true)) {
            return [
                'action' => $action,
                'scan_id' => $scanId,
                'targets' => 0,
                'eligible_targets' => 0,
                'queued_jobs' => 0,
                'skipped' => 0,
                'session_id' => '',
            ];
        }

        $limit = isset($opts['limit']) ? (int) $opts['limit'] : 500;
        $limit = max(1, min(5000, $limit));

        $issueCode = isset($filters['issue_code']) ? sanitize_key((string) $filters['issue_code']) : '';
        $fields = self::fieldsForIssueCode($issueCode);

        $onlyIfEmpty = isset($opts['only_if_empty']) ? (bool) $opts['only_if_empty'] : (strpos($issueCode, 'missing_') === 0);
        $overwriteSuggestions = !empty($opts['overwrite_suggestions']);

        $actorUserId = isset($opts['actor_user_id']) ? (int) $opts['actor_user_id'] : 0;
        $actorUserId = max(0, $actorUserId);

        // Targets list (distinct objects). For generation actions we also carry
        // the per-object issue codes so the generator can narrow changes to the
        // affected SEO fields even when the current filter is "All issues".
        $targets = [];
        if ($action === 'generate_local' || $action === 'generate_apply_local') {
            if ($scanId > 0) {
                $targets = $this->scanIssues->listDistinctObjectsWithIssueCodes($scanId, $filters, $limit, 0);
            } else {
                $targets = $this->issues->listDistinctObjectsWithIssueCodes($filters, $limit, 0);
            }
        } else {
            if ($scanId > 0) {
                $targets = $this->scanIssues->listDistinctObjects($scanId, $filters, $limit, 0);
            } else {
                $targets = $this->issues->listDistinctObjects($filters, $limit, 0);
            }
        }

        if (empty($targets)) {
            return [
                'action' => $action,
                'scan_id' => $scanId,
                'targets' => 0,
                'eligible_targets' => 0,
                'queued_jobs' => 0,
                'skipped' => 0,
                'session_id' => '',
            ];
        }

        $queued = 0;
        $skipped = 0;
        $eligibleTargets = 0;

        $sessionRepo = new BulkQueueSessionRepository($this->logger);
        $sessionId = $sessionRepo->create([
            'action' => $action,
            'source' => 'audit',
            'scan_id' => $scanId,
            'created_by' => $actorUserId,
            'targets' => count($targets),
            'eligible_targets' => 0,
            'queued_jobs' => 0,
            'skipped' => 0,
            'preferred_profile' => isset($opts['preferred_profile']) ? sanitize_key((string) $opts['preferred_profile']) : '',
            'return_url' => isset($opts['return_url']) ? (string) $opts['return_url'] : '',
        ]);

        $this->seedBulkSessionReportMeta($sessionId, $filters);

        $pendingApplyJobs = [];

        foreach ($targets as $t) {
            if (!is_array($t)) {
                continue;
            }
            $objType = isset($t['object_type']) ? sanitize_key((string) $t['object_type']) : '';
            $objId = isset($t['object_id']) ? (int) $t['object_id'] : 0;
            $subType = isset($t['object_subtype']) ? sanitize_key((string) $t['object_subtype']) : '';
            if ($objId <= 0 || $objType === '') {
                continue;
            }

            if ($action === 'generate_local' || $action === 'generate_apply_local') {
                if (!in_array($objType, ['post', 'term'], true)) {
                    $skipped++;
                    continue;
                }
                $eligibleTargets++;

                $issueCodes = [];
                if (isset($t['issue_codes']) && is_array($t['issue_codes'])) {
                    foreach ($t['issue_codes'] as $code) {
                        $code = sanitize_key((string) $code);
                        if ($code !== '') {
                            $issueCodes[$code] = 1;
                        }
                    }
                }

                if (empty($issueCodes) && $issueCode !== '') {
                    $issueCodes[$issueCode] = 1;
                }

                $objectIssueCodes = array_keys($issueCodes);
                $objectFields = $fields;
                if ($issueCode === '' && !empty($objectIssueCodes)) {
                    $hasTitle = false;
                    $hasDesc = false;
                    foreach ($objectIssueCodes as $ic) {
                        $mapped = self::fieldsForIssueCode((string) $ic);
                        if (in_array('title', $mapped, true)) {
                            $hasTitle = true;
                        }
                        if (in_array('description', $mapped, true)) {
                            $hasDesc = true;
                        }
                        if ($hasTitle && $hasDesc) {
                            break;
                        }
                    }

                    if ($hasTitle && !$hasDesc) {
                        $objectFields = ['title'];
                    } elseif ($hasDesc && !$hasTitle) {
                        $objectFields = ['description'];
                    } else {
                        $objectFields = ['title', 'description'];
                    }
                }

                $this->queue->enqueue('bulk_generate', [
                    '__priority' => 2,
                    'object_type' => $objType,
                    'object_id' => $objId,
                    'object_subtype' => $subType,
                    'post_id' => $objType === 'post' ? $objId : 0,
                    'user_id' => $actorUserId,
                    'source' => 'local',
                    'fields' => $objectFields,
                    'issue_code' => (string) ($objectIssueCodes[0] ?? $issueCode),
                    'issue_codes' => $objectIssueCodes,
                    'only_if_empty' => $onlyIfEmpty ? 1 : 0,
                    'overwrite_suggestions' => $overwriteSuggestions ? 1 : 0,
                    'bulk_session_id' => $sessionId,
                ]);
                $queued++;

                if ($action === 'generate_apply_local') {
                    $pendingApplyJobs[] = [
                        '__priority' => 4,
                        'object_type' => $objType,
                        'object_id' => $objId,
                        'object_subtype' => $subType,
                        'post_id' => $objType === 'post' ? $objId : 0,
                        'apply_only_changed' => 1,
                        'bulk_session_id' => $sessionId,
                    ];
                }

                continue;
            }

            // Meta clearing actions (posts + terms).
            $eligibleTargets++;
            $this->queue->enqueue(self::JOB_BULK_FIX, [
                '__priority' => 2,
                'action' => $action,
                'object_type' => $objType,
                'object_id' => $objId,
                'object_subtype' => $subType,
                'actor_user_id' => $actorUserId,
                'bulk_session_id' => $sessionId,
            ]);
            $queued++;
        }

        if (!empty($pendingApplyJobs)) {
            foreach ($pendingApplyJobs as $applyPayload) {
                $this->queue->enqueue('bulk_apply', $applyPayload);
                $queued++;
            }
        }

        $sessionRepo->updateMeta($sessionId, [
            'targets' => count($targets),
            'eligible_targets' => $eligibleTargets,
            'queued_jobs' => $queued,
            'skipped' => $skipped,
        ]);

        return [
            'action' => $action,
            'scan_id' => $scanId,
            'targets' => count($targets),
            'eligible_targets' => $eligibleTargets,
            'queued_jobs' => $queued,
            'skipped' => $skipped,
            'session_id' => $sessionId,
        ];
    }



    /**
     * Schedule bulk action for an explicit list of targets (selected rows).
     *
     * This is used by the UI "bulk for selected" flow, where the user checks
     * specific issues and wants to run a queued fix only for those objects.
     *
     * @param array<int,array<string,mixed>> $targets
     * @param array<string,mixed> $opts
     * @return array{action:string,scan_id:int,targets:int,eligible_targets:int,queued_jobs:int,skipped:int,session_id:string}
     */
    public function scheduleBulkActionForTargets(array $targets, string $action, array $opts = []): array
    {
        $action = sanitize_key((string) $action);
        $allowed = ['generate_local', 'generate_apply_local', 'clear_canonical', 'clear_noindex'];
        if (!in_array($action, $allowed, true)) {
            return [
                'action' => $action,
                'scan_id' => 0,
                'targets' => 0,
                'eligible_targets' => 0,
                'queued_jobs' => 0,
                'skipped' => 0,
                'session_id' => '',
            ];
        }

        $onlyIfEmpty = array_key_exists('only_if_empty', $opts) ? (bool) $opts['only_if_empty'] : null;
        $overwriteSuggestions = !empty($opts['overwrite_suggestions']);

        $actorUserId = isset($opts['actor_user_id']) ? (int) $opts['actor_user_id'] : 0;
        $actorUserId = max(0, $actorUserId);

        // Normalize and de-duplicate targets.
        $norm = [];
        foreach ($targets as $t) {
            if (!is_array($t)) {
                continue;
            }
            $objType = isset($t['object_type']) ? sanitize_key((string) $t['object_type']) : '';
            $objId = isset($t['object_id']) ? (int) $t['object_id'] : 0;
            $subType = isset($t['object_subtype']) ? sanitize_key((string) $t['object_subtype']) : '';
            $issueCode = isset($t['issue_code']) ? sanitize_key((string) $t['issue_code']) : '';

            if ($objType === '' || $objId <= 0) {
                continue;
            }

            $key = $objType . ':' . $subType . ':' . $objId;
            if (!isset($norm[$key])) {
                $norm[$key] = [
                    'object_type' => $objType,
                    'object_id' => $objId,
                    'object_subtype' => $subType,
                    'issue_codes' => [],
                ];
            }
            if ($issueCode !== '') {
                $norm[$key]['issue_codes'][$issueCode] = 1;
            }
        }

        if (empty($norm)) {
            return [
                'action' => $action,
                'scan_id' => 0,
                'targets' => 0,
                'eligible_targets' => 0,
                'queued_jobs' => 0,
                'skipped' => 0,
                'session_id' => '',
            ];
        }

        $queued = 0;
        $skipped = 0;

        $sessionRepo = new BulkQueueSessionRepository($this->logger);
        $sessionId = $sessionRepo->create([
            'action' => $action,
            'source' => 'audit',
            'scan_id' => 0,
            'created_by' => $actorUserId,
            'targets' => count($norm),
            'eligible_targets' => 0,
            'queued_jobs' => 0,
            'skipped' => 0,
            'preferred_profile' => isset($opts['preferred_profile']) ? sanitize_key((string) $opts['preferred_profile']) : '',
            'return_url' => isset($opts['return_url']) ? (string) $opts['return_url'] : '',
        ]);

        $this->seedBulkSessionReportMeta($sessionId);

        // For meta generation actions, combine fields per object.
        if ($action === 'generate_local' || $action === 'generate_apply_local') {
            $objects = [];
            $pendingApplyJobs = [];
            foreach ($norm as $key => $t) {
                $objType = (string) ($t['object_type'] ?? '');
                $objId = (int) ($t['object_id'] ?? 0);
                $subType = (string) ($t['object_subtype'] ?? '');
                if (!in_array($objType, ['post', 'term'], true) || $objId <= 0) {
                    $skipped++;
                    continue;
                }

                $issueCodes = isset($t['issue_codes']) && is_array($t['issue_codes']) ? array_keys($t['issue_codes']) : [];
                $fields = ['title', 'description'];

                // Narrow fields if we can infer it from issue codes.
                $hasTitle = false;
                $hasDesc = false;
                foreach ($issueCodes as $ic) {
                    $mapped = self::fieldsForIssueCode((string) $ic);
                    if (in_array('title', $mapped, true) && !in_array('description', $mapped, true)) {
                        $hasTitle = true;
                    }
                    if (in_array('description', $mapped, true) && !in_array('title', $mapped, true)) {
                        $hasDesc = true;
                    }
                }

                if ($hasTitle && !$hasDesc) {
                    $fields = ['title'];
                } elseif ($hasDesc && !$hasTitle) {
                    $fields = ['description'];
                }

                if (!isset($objects[$key])) {
                    $objects[$key] = [
                        'object_type' => $objType,
                        'object_id' => $objId,
                        'object_subtype' => $subType,
                        'issue_codes' => $issueCodes,
                        'fields' => [],
                        'only_if_empty' => $onlyIfEmpty,
                    ];
                }

                if ($objects[$key]['only_if_empty'] === null) {
                    $objects[$key]['only_if_empty'] = self::defaultOnlyIfEmptyForIssueCodes($issueCodes);
                }

                foreach ($fields as $f) {
                    $objects[$key]['fields'][$f] = 1;
                }
            }

            foreach ($objects as $object) {
                $objType = (string) ($object['object_type'] ?? 'post');
                $objId = (int) ($object['object_id'] ?? 0);
                $subType = (string) ($object['object_subtype'] ?? '');
                $issueCodes = isset($object['issue_codes']) && is_array($object['issue_codes']) ? array_values($object['issue_codes']) : [];
                $fields = isset($object['fields']) && is_array($object['fields']) ? array_keys($object['fields']) : [];
                if (empty($fields)) {
                    $fields = ['title', 'description'];
                }

                $this->queue->enqueue('bulk_generate', [
                    '__priority' => 2,
                    'object_type' => $objType,
                    'object_id' => $objId,
                    'object_subtype' => $subType,
                    'post_id' => $objType === 'post' ? $objId : 0,
                    'user_id' => $actorUserId,
                    'source' => 'local',
                    'fields' => $fields,
                    'issue_codes' => $issueCodes,
                    'issue_code' => (string) ($issueCodes[0] ?? ''),
                    'only_if_empty' => !empty($object['only_if_empty']) ? 1 : 0,
                    'overwrite_suggestions' => $overwriteSuggestions ? 1 : 0,
                    'bulk_session_id' => $sessionId,
                ]);
                $queued++;

                if ($action === 'generate_apply_local') {
                    $pendingApplyJobs[] = [
                        '__priority' => 4,
                        'object_type' => $objType,
                        'object_id' => $objId,
                        'object_subtype' => $subType,
                        'post_id' => $objType === 'post' ? $objId : 0,
                        'apply_only_changed' => 1,
                        'bulk_session_id' => $sessionId,
                    ];
                }
            }

            if (!empty($pendingApplyJobs)) {
                foreach ($pendingApplyJobs as $applyPayload) {
                    $this->queue->enqueue('bulk_apply', $applyPayload);
                    $queued++;
                }
            }

            $sessionRepo->updateMeta($sessionId, [
                'targets' => count($norm),
                'eligible_targets' => count($objects),
                'queued_jobs' => $queued,
                'skipped' => $skipped,
            ]);

            return [
                'action' => $action,
                'scan_id' => 0,
                'targets' => count($norm),
                'eligible_targets' => count($objects),
                'queued_jobs' => $queued,
                'skipped' => $skipped,
                'session_id' => $sessionId,
            ];
        }

        // Meta clearing actions (posts + terms).
        foreach ($norm as $t) {
            $objType = (string) ($t['object_type'] ?? '');
            $objId = (int) ($t['object_id'] ?? 0);
            $subType = (string) ($t['object_subtype'] ?? '');
            if ($objType === '' || $objId <= 0) {
                continue;
            }
            if (!in_array($objType, ['post', 'term'], true)) {
                $skipped++;
                continue;
            }

            $this->queue->enqueue(self::JOB_BULK_FIX, [
                '__priority' => 2,
                'action' => $action,
                'object_type' => $objType,
                'object_id' => $objId,
                'object_subtype' => $subType,
                'actor_user_id' => $actorUserId,
                'bulk_session_id' => $sessionId,
            ]);
            $queued++;
        }

        $sessionRepo->updateMeta($sessionId, [
            'targets' => count($norm),
            'eligible_targets' => $queued,
            'queued_jobs' => $queued,
            'skipped' => $skipped,
        ]);

        return [
            'action' => $action,
            'scan_id' => 0,
            'targets' => count($norm),
            'eligible_targets' => $queued,
            'queued_jobs' => $queued,
            'skipped' => $skipped,
            'session_id' => $sessionId,
        ];
    }



    /**
     * Queue handler for bulk fixes (meta clears).
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     * @return mixed
     */
    public function handleBulkFix($unused, array $payload, array $job)
    {
        $action = isset($payload['action']) ? sanitize_key((string) $payload['action']) : '';
        $objType = isset($payload['object_type']) ? sanitize_key((string) $payload['object_type']) : '';
        $objId = isset($payload['object_id']) ? (int) $payload['object_id'] : 0;
        $subType = isset($payload['object_subtype']) ? sanitize_key((string) $payload['object_subtype']) : '';
        $actorUserId = isset($payload['actor_user_id']) ? (int) $payload['actor_user_id'] : 0;
        $actorUserId = max(0, $actorUserId);
        $bulkSessionId = isset($payload['bulk_session_id']) ? sanitize_key((string) $payload['bulk_session_id']) : '';

        if ($objId <= 0 || $objType === '' || $action === '') {
            return null;
        }

        if (!in_array($action, ['clear_canonical', 'clear_noindex'], true)) {
            return null;
        }

        $metaKey = ($action === 'clear_canonical') ? MetaKeys::CANONICAL : MetaKeys::ROBOTS_INDEX;
        $reportRepo = $bulkSessionId !== '' ? new BulkSessionReportRepository() : null;
        if ($reportRepo instanceof BulkSessionReportRepository) {
            $reportRepo->touch($bulkSessionId);
        }

        if ($objType === 'post') {
            $before = (string) get_post_meta($objId, $metaKey, true);
            if ($before === '') {
                if ($reportRepo instanceof BulkSessionReportRepository) {
                    $reportRepo->recordFix($bulkSessionId, [
                        'object_type' => 'post',
                        'object_id' => $objId,
                        'object_subtype' => $subType,
                        'fix_status' => 'skipped_empty',
                        'fix_action' => $action,
                        'fix_note' => __('Skipped because there was nothing to clear on this object.', 'wp-seo-next'),
                    ]);
                }
                return null;
            }

            if ($this->settings->moduleEnabled('revisions', true)) {
                try {
                    RevisionsInstaller::maybeInstall();
                    $revRepo = new RevisionsRepository($this->logger);

                    $p = get_post($objId);
                    $revRepo->insert([
                        'object_type' => 'post',
                        'object_id' => $objId,
                        'object_subtype' => $p ? (string) $p->post_type : $subType,
                        'action' => 'clear',
                        'source' => 'audit',
                        'actor_user_id' => $actorUserId > 0 ? $actorUserId : (int) get_current_user_id(),
                        'before' => ['meta' => [$metaKey => $before]],
                        'after'  => ['meta' => [$metaKey => '']],
                        'meta' => [
                            'bulk_action' => $action,
                        ],
                    ]);
                } catch (\Throwable $e) {
                    // ignore
                }
            }

            delete_post_meta($objId, $metaKey);

            if ($reportRepo instanceof BulkSessionReportRepository) {
                $reportRepo->recordFix($bulkSessionId, [
                    'object_type' => 'post',
                    'object_id' => $objId,
                    'object_subtype' => $subType,
                    'fix_status' => 'applied',
                    'fix_action' => $action,
                    'before_title' => $metaKey === MetaKeys::CANONICAL ? $before : '',
                    'before_description' => $metaKey === MetaKeys::ROBOTS_INDEX ? $before : '',
                    'after_title' => '',
                    'after_description' => '',
                    'fix_note' => __('Cleared the requested SEO override on this object.', 'wp-seo-next'),
                ]);
            }

            do_action('seo/meta/updated', $objId, 'post');
            $refreshMeta = ['source' => 'audit_bulk_fix', 'action' => $action];
            if ($bulkSessionId !== '') {
                $refreshMeta['bulk_session_id'] = $bulkSessionId;
            }
            $this->enqueueObjectRefresh('post', $objId, $subType, $refreshMeta);
            return null;
        }

        if ($objType === 'term') {
            $before = (string) get_term_meta($objId, $metaKey, true);
            if ($before === '') {
                if ($reportRepo instanceof BulkSessionReportRepository) {
                    $reportRepo->recordFix($bulkSessionId, [
                        'object_type' => 'term',
                        'object_id' => $objId,
                        'object_subtype' => $subType,
                        'fix_status' => 'skipped_empty',
                        'fix_action' => $action,
                        'fix_note' => __('Skipped because there was nothing to clear on this object.', 'wp-seo-next'),
                    ]);
                }
                return null;
            }

            if ($this->settings->moduleEnabled('revisions', true)) {
                try {
                    RevisionsInstaller::maybeInstall();
                    $revRepo = new RevisionsRepository($this->logger);

                    $term = get_term($objId);
                    $tax = is_object($term) && isset($term->taxonomy) ? (string) $term->taxonomy : $subType;

                    $revRepo->insert([
                        'object_type' => 'term',
                        'object_id' => $objId,
                        'object_subtype' => $tax,
                        'action' => 'clear',
                        'source' => 'audit',
                        'actor_user_id' => $actorUserId > 0 ? $actorUserId : (int) get_current_user_id(),
                        'before' => ['meta' => [$metaKey => $before]],
                        'after'  => ['meta' => [$metaKey => '']],
                        'meta' => [
                            'bulk_action' => $action,
                        ],
                    ]);
                } catch (\Throwable $e) {
                    // ignore
                }
            }

            delete_term_meta($objId, $metaKey);
            if ($reportRepo instanceof BulkSessionReportRepository) {
                $reportRepo->recordFix($bulkSessionId, [
                    'object_type' => 'term',
                    'object_id' => $objId,
                    'object_subtype' => $subType,
                    'fix_status' => 'applied',
                    'fix_action' => $action,
                    'before_title' => $metaKey === MetaKeys::CANONICAL ? $before : '',
                    'before_description' => $metaKey === MetaKeys::ROBOTS_INDEX ? $before : '',
                    'after_title' => '',
                    'after_description' => '',
                    'fix_note' => __('Cleared the requested SEO override on this object.', 'wp-seo-next'),
                ]);
            }
            do_action('seo/meta/updated', $objId, 'term');
            $refreshMeta = ['source' => 'audit_bulk_fix', 'action' => $action];
            if ($bulkSessionId !== '') {
                $refreshMeta['bulk_session_id'] = $bulkSessionId;
            }
            $this->enqueueObjectRefresh('term', $objId, $subType, $refreshMeta);
            return null;
        }

        return null;
    }

    /**
     * Refresh the live "latest issues" rows for a single post/term.
     *
     * This runs after queued meta changes so users do not need a full site audit
     * just to see that one fixed object disappeared from SEO Issues.
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    public function handleRefreshObjectIssues($unused, array $payload, array $job): array
    {
        $objectType = isset($payload['object_type']) ? sanitize_key((string) $payload['object_type']) : '';
        $objectId = isset($payload['object_id']) ? (int) $payload['object_id'] : 0;
        $objectSubType = isset($payload['object_subtype']) ? sanitize_key((string) $payload['object_subtype']) : '';
        $bulkSessionId = isset($payload['bulk_session_id']) ? sanitize_key((string) $payload['bulk_session_id']) : '';

        if (!in_array($objectType, ['post', 'term'], true) || $objectId <= 0) {
            return ['status' => 'done'];
        }

        $latest = get_option('wpsn_audit_last_scan', []);
        $scanId = is_array($latest) ? (int) ($latest['scan_id'] ?? 0) : 0;

        $oldTitleKey = '';
        $oldDescKey = '';
        $oldTitleRows = [];
        $oldDescRows = [];
        if ($scanId > 0) {
            $oldTitleKey = $this->titleKeys->keyForObject($scanId, $objectType, $objectId);
            $oldDescKey = $this->descKeys->keyForObject($scanId, $objectType, $objectId);

            if ($oldTitleKey !== '') {
                $oldTitleRows = $this->titleKeys->rowsForKey($scanId, $objectType, $oldTitleKey);
            }
            if ($oldDescKey !== '') {
                $oldDescRows = $this->descKeys->rowsForKey($scanId, $objectType, $oldDescKey);
            }
        }

        $this->issues->deleteByObject($objectType, $objectId);

        $checkSocial = true;
        if ($scanId > 0) {
            $scan = $this->scans->get($scanId);
            if (is_array($scan) && array_key_exists('check_social', $scan)) {
                $checkSocial = !empty($scan['check_social']);
            }
        }

        $runner = new AuditRunner($this->metaRepo, $this->issues, $this->scans, $this->scanIssues, $this->logger, $this->settings);
        $freshIssues = [];

        if ($objectType === 'post') {
            if ($objectSubType === '') {
                $objectSubType = sanitize_key((string) get_post_type($objectId));
            }

            if ($objectSubType !== '' && get_post_status($objectId) === 'publish') {
                $freshIssues = $runner->auditPost($objectId, $objectSubType, $checkSocial);
            }
        } else {
            if ($objectSubType === '') {
                $term = get_term($objectId);
                if ($term && !is_wp_error($term) && isset($term->taxonomy)) {
                    $objectSubType = sanitize_key((string) $term->taxonomy);
                }
            }

            if ($objectSubType !== '') {
                $term = get_term($objectId, $objectSubType);
                if ($term && !is_wp_error($term)) {
                    $freshIssues = $runner->auditTerm($objectId, $objectSubType);
                }
            }
        }

        foreach ($freshIssues as $issue) {
            if (is_array($issue)) {
                $this->issues->insert($issue);
            }
        }

        if ($scanId > 0 && $objectSubType !== '') {
            $this->titleKeys->deleteForObject($scanId, $objectType, $objectId);
            $this->descKeys->deleteForObject($scanId, $objectType, $objectId);

            $newTitleKey = '';
            $newDescKey = '';
            $newTitleRows = [];
            $newDescRows = [];

            if ($objectType === 'post') {
                $newTitleKey = $runner->titleKeyForPost($objectId, $objectSubType);
                $newDescKey = $runner->descriptionKeyForPost($objectId, $objectSubType);
            } else {
                $newTitleKey = $runner->titleKeyForTerm($objectId, $objectSubType);
                $newDescKey = $runner->descriptionKeyForTerm($objectId, $objectSubType);
            }

            if ($newTitleKey !== '') {
                $this->titleKeys->upsert($scanId, $objectType, $objectId, $objectSubType, $newTitleKey);
                $newTitleRows = $this->titleKeys->rowsForKey($scanId, $objectType, $newTitleKey);
            }
            if ($newDescKey !== '') {
                $this->descKeys->upsert($scanId, $objectType, $objectId, $objectSubType, $newDescKey);
                $newDescRows = $this->descKeys->rowsForKey($scanId, $objectType, $newDescKey);
            }

            $titleAffectedIds = $this->mergeDuplicateGroupObjectIds($oldTitleRows, $newTitleRows, $objectId);
            if (!empty($titleAffectedIds)) {
                $this->issues->deleteByObjectsAndCodes($objectType, $titleAffectedIds, [AuditRunner::ISSUE_DUPLICATE_TITLE]);
                $this->syncLatestDuplicateIssueGroupForKey(
                    $scanId,
                    $objectType,
                    $oldTitleKey,
                    AuditRunner::ISSUE_DUPLICATE_TITLE,
                    AuditRunner::SEVERITY_MEDIUM,
                    __('Duplicate SEO title (%d pages)', 'wp-seo-next'),
                    __('Make the SEO titles unique across pages.', 'wp-seo-next'),
                    'title_key'
                );
                if ($newTitleKey !== $oldTitleKey) {
                    $this->syncLatestDuplicateIssueGroupForKey(
                        $scanId,
                        $objectType,
                        $newTitleKey,
                        AuditRunner::ISSUE_DUPLICATE_TITLE,
                        AuditRunner::SEVERITY_MEDIUM,
                        __('Duplicate SEO title (%d pages)', 'wp-seo-next'),
                        __('Make the SEO titles unique across pages.', 'wp-seo-next'),
                        'title_key'
                    );
                }
            }

            $descAffectedIds = $this->mergeDuplicateGroupObjectIds($oldDescRows, $newDescRows, $objectId);
            if (!empty($descAffectedIds)) {
                $this->issues->deleteByObjectsAndCodes($objectType, $descAffectedIds, [AuditRunner::ISSUE_DUPLICATE_DESCRIPTION]);
                $this->syncLatestDuplicateIssueGroupForKey(
                    $scanId,
                    $objectType,
                    $oldDescKey,
                    AuditRunner::ISSUE_DUPLICATE_DESCRIPTION,
                    AuditRunner::SEVERITY_LOW,
                    __('Duplicate meta description (%d pages)', 'wp-seo-next'),
                    __('Make meta descriptions unique across pages.', 'wp-seo-next'),
                    'description_key'
                );
                if ($newDescKey !== $oldDescKey) {
                    $this->syncLatestDuplicateIssueGroupForKey(
                        $scanId,
                        $objectType,
                        $newDescKey,
                        AuditRunner::ISSUE_DUPLICATE_DESCRIPTION,
                        AuditRunner::SEVERITY_LOW,
                        __('Duplicate meta description (%d pages)', 'wp-seo-next'),
                        __('Make meta descriptions unique across pages.', 'wp-seo-next'),
                        'description_key'
                    );
                }
            }
        }

        $liveSnapshot = $this->refreshLatestSummaryFromIssues($bulkSessionId);

        return [
            'status' => 'done',
            'object_type' => $objectType,
            'object_id' => $objectId,
            'issues_count' => count($freshIssues),
            'live_issues_total' => (int) ($liveSnapshot['total'] ?? 0),
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function refreshLatestSummaryFromIssues(string $bulkSessionId = ''): array
    {
        $counts = $this->issues->countsByCode();
        $issuesTotal = 0;
        foreach ($counts as $count) {
            $issuesTotal += (int) $count;
        }

        $summary = get_option('wpsn_audit_last_scan', []);
        if (!is_array($summary)) {
            $summary = [];
        }

        // Do not rewrite historical scan totals here. This path is triggered by
        // queued bulk/apply refreshes and should only describe the live/latest
        // issues list that the user is currently browsing.
        $summary['live_issues_total'] = $issuesTotal;
        $summary['live_issues_by_code'] = $counts;
        $summary['live_refreshed_at'] = time();
        $summary['live_source'] = 'latest_refresh';
        $summary['live_source_scan_id'] = isset($summary['scan_id']) ? (int) $summary['scan_id'] : 0;
        update_option('wpsn_audit_last_scan', $summary, false);

        $snapshot = [
            'captured_at' => time(),
            'total' => $issuesTotal,
            'counts_by_code' => $counts,
            'source_scan_id' => (int) ($summary['live_source_scan_id'] ?? 0),
            'live_source' => (string) ($summary['live_source'] ?? ''),
        ];

        if ($bulkSessionId !== '') {
            (new BulkSessionReportRepository())->touch($bulkSessionId, [
                'live_snapshot_after' => $snapshot,
            ]);
        }

        return $snapshot;
    }

    /**
     * @param array<string,mixed> $filters
     */
    private function seedBulkSessionReportMeta(string $sessionId, array $filters = []): void
    {
        $sessionId = sanitize_key($sessionId);
        if ($sessionId === '') {
            return;
        }

        $meta = [
            'live_snapshot_before' => $this->currentLiveIssueSnapshot(),
        ];

        $cleanFilters = [];
        foreach ($filters as $key => $value) {
            if (is_scalar($value) || $value === null) {
                $cleanFilters[(string) $key] = $value;
            }
        }
        if (!empty($cleanFilters)) {
            $meta['audit_filters'] = $cleanFilters;
        }

        (new BulkSessionReportRepository())->touch($sessionId, $meta);
    }

    /**
     * @return array<string,mixed>
     */
    private function currentLiveIssueSnapshot(): array
    {
        $counts = $this->issues->countsByCode();
        ksort($counts);

        $total = 0;
        foreach ($counts as $count) {
            $total += (int) $count;
        }

        $lastScan = get_option('wpsn_audit_last_scan', []);
        if (!is_array($lastScan)) {
            $lastScan = [];
        }

        return [
            'captured_at' => time(),
            'total' => $total,
            'counts_by_code' => $counts,
            'source_scan_id' => isset($lastScan['live_source_scan_id']) ? (int) $lastScan['live_source_scan_id'] : (isset($lastScan['scan_id']) ? (int) $lastScan['scan_id'] : 0),
            'live_source' => isset($lastScan['live_source']) ? sanitize_key((string) $lastScan['live_source']) : '',
        ];
    }

    /**
     * @param array<string,mixed> $meta
     */
    private function enqueueObjectRefresh(string $objectType, int $objectId, string $objectSubType = '', array $meta = []): void
    {
        $objectType = sanitize_key($objectType);
        $objectSubType = sanitize_key($objectSubType);
        $objectId = (int) $objectId;
        if (!in_array($objectType, ['post', 'term'], true) || $objectId <= 0) {
            return;
        }

        $payload = array_merge($meta, [
            'object_type' => $objectType,
            'object_id' => $objectId,
            'object_subtype' => $objectSubType,
        ]);

        try {
            $this->queue->enqueue(self::JOB_REFRESH_OBJECT_ISSUES, $payload);
        } catch (\Throwable $e) {
            $this->logger->warning('Audit object refresh enqueue failed: ' . $e->getMessage(), [
                'object_type' => $objectType,
                'object_id' => $objectId,
            ]);
        }
    }

    /**
     * @param array<int,array{object_id:int,object_subtype:string}> $rowsA
     * @param array<int,array{object_id:int,object_subtype:string}> $rowsB
     * @return array<int,int>
     */
    private function mergeDuplicateGroupObjectIds(array $rowsA, array $rowsB, int $objectId): array
    {
        $ids = [$objectId];

        foreach ([$rowsA, $rowsB] as $rows) {
            foreach ($rows as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $id = isset($row['object_id']) ? (int) $row['object_id'] : 0;
                if ($id > 0) {
                    $ids[] = $id;
                }
            }
        }

        $ids = array_values(array_unique(array_filter(array_map('intval', $ids), static fn ($v) => $v > 0)));
        sort($ids);

        return $ids;
    }

    private function syncLatestDuplicateIssueGroupForKey(
        int $scanId,
        string $objectType,
        string $key,
        string $issueCode,
        int $severity,
        string $messageTemplate,
        string $suggestion,
        string $detailKey
    ): void {
        $scanId = (int) $scanId;
        $objectType = sanitize_key($objectType);
        $key = (string) $key;

        if ($scanId <= 0 || $objectType === '' || $key === '') {
            return;
        }

        $rows = ($detailKey === 'description_key')
            ? $this->descKeys->rowsForKey($scanId, $objectType, $key)
            : $this->titleKeys->rowsForKey($scanId, $objectType, $key);

        if (count($rows) < 2) {
            return;
        }

        $this->persistLatestDuplicateIssueGroup($objectType, $key, $rows, $issueCode, $severity, $messageTemplate, $suggestion, $detailKey);
    }

    /**
     * @param array<int,array{object_id:int,object_subtype:string}> $rows
     */
    private function persistLatestDuplicateIssueGroup(
        string $objectType,
        string $key,
        array $rows,
        string $issueCode,
        int $severity,
        string $messageTemplate,
        string $suggestion,
        string $detailKey
    ): void {
        $objectType = sanitize_key($objectType);
        if ($objectType === '' || $key === '' || count($rows) < 2) {
            return;
        }

        $count = count($rows);
        $allIds = [];
        $allSubTypes = [];

        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }
            $rowObjectId = isset($row['object_id']) ? (int) $row['object_id'] : 0;
            $rowSubType = isset($row['object_subtype']) ? sanitize_key((string) $row['object_subtype']) : '';
            if ($rowObjectId <= 0) {
                continue;
            }
            $allIds[] = $rowObjectId;
            if ($rowSubType !== '') {
                $allSubTypes[$rowSubType] = true;
            }
        }

        if (count($allIds) < 2) {
            return;
        }

        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }
            $rowObjectId = isset($row['object_id']) ? (int) $row['object_id'] : 0;
            $rowSubType = isset($row['object_subtype']) ? sanitize_key((string) $row['object_subtype']) : '';
            if ($rowObjectId <= 0) {
                continue;
            }

            $ref = $this->resolveDuplicateObjectRef($objectType, $rowObjectId, $rowSubType);
            $issueRow = [
                'object_type' => $objectType,
                'object_id' => $rowObjectId,
                'object_subtype' => (string) ($ref['object_subtype'] ?? $rowSubType),
                'object_title' => (string) ($ref['object_title'] ?? ''),
                'object_url' => (string) ($ref['object_url'] ?? ''),
                'issue_code' => $issueCode,
                'severity' => $severity,
                'message' => sprintf(
                    /* translators: %d is number of pages */
                    $messageTemplate,
                    $count
                ),
                'suggestion' => $suggestion,
                'details' => wp_json_encode([
                    'group_size' => $count,
                    $detailKey => $key,
                    'all_ids' => array_values(array_map('intval', $allIds)),
                    'all_subtypes' => array_values(array_keys($allSubTypes)),
                ]),
                'detected_at' => current_time('mysql'),
                'updated_at' => current_time('mysql'),
            ];

            $this->issues->insert($issueRow);
        }
    }

    public function handleScanPosts($unused, array $payload, array $job)
    {
        $scanId = isset($payload['scan_id']) ? (int) $payload['scan_id'] : 0;
        $postType = isset($payload['post_type']) ? sanitize_key((string) $payload['post_type']) : '';
        $page = isset($payload['page']) ? (int) $payload['page'] : 1;
        $perPage = isset($payload['per_page']) ? (int) $payload['per_page'] : self::POSTS_PER_BATCH;
        $perPage = max(1, min(500, $perPage));
        $page = max(1, $page);

        $config = isset($payload['config']) && is_array($payload['config']) ? $payload['config'] : [];

        if ($scanId <= 0 || $postType === '') {
            return null;
        }

        $scan = $this->scans->get($scanId);
        if (!is_array($scan)) {
            return null;
        }
        if (isset($scan['status']) && (string) $scan['status'] !== 'running') {
            return null;
        }

        $checkSocial = !empty($scan['check_social']);

        $q = new \WP_Query([
            'post_type' => $postType,
            'post_status' => 'publish',
            'posts_per_page' => $perPage,
            'paged' => $page,
            'fields' => 'ids',
            'no_found_rows' => true,
            'ignore_sticky_posts' => true,
            'orderby' => 'ID',
            'order' => 'ASC',
        ]);

        $ids = is_array($q->posts) ? array_map('intval', $q->posts) : [];
        $ids = array_values(array_filter($ids, static fn($v) => $v > 0));
        if (empty($ids)) {
            // Still mark phase as posts for progress.
            $this->scans->incrementPosts($scanId, 0, 0, 0, 0);
            return null;
        }

        $runner = new AuditRunner($this->metaRepo, $this->issues, $this->scans, $this->scanIssues, $this->logger, $this->settings);
        $runner->applyScanConfig($config);

        $processed = 0;
        foreach ($ids as $idx => $postId) {
            // Stop quickly if user cancels scan.
            if ($idx % 5 === 0) {
                $scanNow = $this->scans->get($scanId);
                if (!is_array($scanNow) || (string) ($scanNow['status'] ?? '') !== 'running') {
                    break;
                }
            }

            $postIssues = $runner->auditPost((int) $postId, $postType, $checkSocial);
            foreach ($postIssues as $issue) {
                if (!is_array($issue)) {
                    continue;
                }
                $this->issues->insert($issue);
                $this->scanIssues->insert($scanId, $issue);
            }

            $key = $runner->titleKeyForPost((int) $postId, $postType);
            if ($key !== '') {
                $this->titleKeys->upsert($scanId, 'post', (int) $postId, $postType, $key);
            }

            $dkey = $runner->descriptionKeyForPost((int) $postId, $postType);
            if ($dkey !== '') {
                $this->descKeys->upsert($scanId, 'post', (int) $postId, $postType, $dkey);
            }

            $processed++;
        }

        $this->scans->incrementPosts(
            $scanId,
            $processed,
            $runner->getNewsEligible(),
            $runner->getVideoCandidates(),
            $runner->getVideoEligible()
        );

        return null;
    }

    /**
     * Queue handler: scan term batch.
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     * @return mixed
     */
    public function handleScanTerms($unused, array $payload, array $job)
    {
        $scanId = isset($payload['scan_id']) ? (int) $payload['scan_id'] : 0;
        $taxonomy = isset($payload['taxonomy']) ? sanitize_key((string) $payload['taxonomy']) : '';
        $offset = isset($payload['offset']) ? (int) $payload['offset'] : 0;
        $number = isset($payload['number']) ? (int) $payload['number'] : self::TERMS_PER_BATCH;
        $number = max(1, min(500, $number));
        $offset = max(0, $offset);
        $config = isset($payload['config']) && is_array($payload['config']) ? $payload['config'] : [];

        if ($scanId <= 0 || $taxonomy === '' || !taxonomy_exists($taxonomy)) {
            return null;
        }

        $scan = $this->scans->get($scanId);
        if (!is_array($scan)) {
            return null;
        }
        if (isset($scan['status']) && (string) $scan['status'] !== 'running') {
            return null;
        }

        $termIds = get_terms([
            'taxonomy' => $taxonomy,
            'hide_empty' => false,
            'fields' => 'ids',
            'number' => $number,
            'offset' => $offset,
        ]);

        if (is_wp_error($termIds) || !is_array($termIds)) {
            return null;
        }

        $termIds = array_values(array_filter(array_map('intval', $termIds), static fn($v) => $v > 0));
        if (empty($termIds)) {
            $this->scans->incrementTerms($scanId, 0);
            return null;
        }

        $runner = new AuditRunner($this->metaRepo, $this->issues, $this->scans, $this->scanIssues, $this->logger, $this->settings);
        $runner->applyScanConfig($config);

        $processed = 0;
        foreach ($termIds as $idx => $termId) {
            // Stop quickly if user cancels scan.
            if ($idx % 10 === 0) {
                $scanNow = $this->scans->get($scanId);
                if (!is_array($scanNow) || (string) ($scanNow['status'] ?? '') !== 'running') {
                    break;
                }
            }

            $termIssues = $runner->auditTerm((int) $termId, $taxonomy);
            foreach ($termIssues as $issue) {
                if (!is_array($issue)) {
                    continue;
                }
                $this->issues->insert($issue);
                $this->scanIssues->insert($scanId, $issue);
            }

            $key = $runner->titleKeyForTerm((int) $termId, $taxonomy);
            if ($key !== '') {
                $this->titleKeys->upsert($scanId, 'term', (int) $termId, $taxonomy, $key);
            }

            $dkey = $runner->descriptionKeyForTerm((int) $termId, $taxonomy);
            if ($dkey !== '') {
                $this->descKeys->upsert($scanId, 'term', (int) $termId, $taxonomy, $dkey);
            }

            $processed++;
        }

        $this->scans->incrementTerms($scanId, $processed);

        return null;
    }

    /**
     * Queue handler: finalize scan (duplicates + system checks) and mark completed.
     *
     * @param mixed $unused
     * @param array<string,mixed> $payload
     * @param array<string,mixed> $job
     * @return mixed
     */
    public function handleFinalize($unused, array $payload, array $job)
    {
        $scanId = isset($payload['scan_id']) ? (int) $payload['scan_id'] : 0;
        if ($scanId <= 0) {
            return null;
        }

        $config = isset($payload['config']) && is_array($payload['config']) ? $payload['config'] : [];
        $startedAtTs = isset($config['scan_started_ts']) ? (int) $config['scan_started_ts'] : time();
        if ($startedAtTs <= 0) {
            $startedAtTs = time();
        }

        $scan = $this->scans->get($scanId);
        if (!is_array($scan)) {
            return null;
        }
        if (isset($scan['status']) && (string) $scan['status'] !== 'running') {
            return null;
        }

        $this->scans->setPhase($scanId, 'finalize');

        try {
            $this->persistDuplicateIssueGroups(
                $scanId,
                $this->titleKeys->duplicateGroups($scanId),
                AuditRunner::ISSUE_DUPLICATE_TITLE,
                AuditRunner::SEVERITY_MEDIUM,
                __('Duplicate SEO title (%d pages)', 'wp-seo-next'),
                __('Make the SEO titles unique across pages.', 'wp-seo-next'),
                'title_key'
            );

            $this->persistDuplicateIssueGroups(
                $scanId,
                $this->descKeys->duplicateGroups($scanId),
                AuditRunner::ISSUE_DUPLICATE_DESCRIPTION,
                AuditRunner::SEVERITY_LOW,
                __('Duplicate meta description (%d pages)', 'wp-seo-next'),
                __('Make meta descriptions unique across pages.', 'wp-seo-next'),
                'description_key'
            );

// Sitemap sanity checks (News/Video) + Local checks.
            $runner = new AuditRunner($this->metaRepo, $this->issues, $this->scans, $this->scanIssues, $this->logger, $this->settings);
            $runner->applyScanConfig($config);

            // News sitemap empty.
            $newsEnabled = !empty($config['news_sitemap_enabled']);
            if ($newsEnabled) {
                $newsEligible = isset($scan['news_eligible']) ? (int) $scan['news_eligible'] : 0;
                if ($newsEligible < 1) {
                    $sUrl = home_url('/news-sitemap.xml');
                    $row = [
                        'object_type'    => 'system',
                        'object_id'      => 0,
                        'object_subtype' => 'sitemap',
                        'object_title'   => __('News sitemap', 'wp-seo-next'),
                        'object_url'     => (string) $sUrl,
                        'issue_code'     => AuditRunner::ISSUE_NEWS_SITEMAP_EMPTY,
                        'severity'       => AuditRunner::SEVERITY_LOW,
                        'message'        => __('News sitemap is empty', 'wp-seo-next'),
                        'suggestion'     => __('Publish at least one indexable post in the last 48 hours (or disable News sitemap in Settings → Sitemap).', 'wp-seo-next'),
                        'details'        => wp_json_encode([
                            'window_hours' => 48,
                            'post_types' => isset($config['news_post_types']) && is_array($config['news_post_types']) ? array_values($config['news_post_types']) : [],
                        ]),
                        'detected_at'    => current_time('mysql'),
                        'updated_at'     => current_time('mysql'),
                    ];
                    $this->issues->insert($row);
                    $this->scanIssues->insert($scanId, $row);
                }
            }

            // Video sitemap empty.
            $videoEnabled = !empty($config['video_sitemap_enabled']);
            if ($videoEnabled) {
                $videoEligible = isset($scan['video_eligible']) ? (int) $scan['video_eligible'] : 0;
                $videoCandidates = isset($scan['video_candidates']) ? (int) $scan['video_candidates'] : 0;
                if ($videoEligible < 1) {
                    $sUrl = home_url('/video-sitemap.xml');
                    $msg = $videoCandidates > 0
                        ? __('Video sitemap has no valid entries', 'wp-seo-next')
                        : __('Video sitemap has no entries', 'wp-seo-next');
                    $suggest = $videoCandidates > 0
                        ? __('Fix the video meta issues on pages with a Video URL (thumbnail/title/description).', 'wp-seo-next')
                        : __('Set a Video URL on pages that contain videos (or disable Video sitemap in Settings → Sitemap).', 'wp-seo-next');

                    $row = [
                        'object_type'    => 'system',
                        'object_id'      => 0,
                        'object_subtype' => 'sitemap',
                        'object_title'   => __('Video sitemap', 'wp-seo-next'),
                        'object_url'     => (string) $sUrl,
                        'issue_code'     => AuditRunner::ISSUE_VIDEO_SITEMAP_EMPTY,
                        'severity'       => AuditRunner::SEVERITY_LOW,
                        'message'        => $msg,
                        'suggestion'     => $suggest,
                        'details'        => wp_json_encode([
                            'candidates' => $videoCandidates,
                            'eligible' => $videoEligible,
                            'mode' => isset($config['video_mode']) ? (string) $config['video_mode'] : 'meta',
                        ]),
                        'detected_at'    => current_time('mysql'),
                        'updated_at'     => current_time('mysql'),
                    ];
                    $this->issues->insert($row);
                    $this->scanIssues->insert($scanId, $row);
                }
            }

            // Local SEO sanity checks.
            $localIssues = $runner->auditLocal();
            foreach ($localIssues as $issue) {
                if (!is_array($issue)) {
                    continue;
                }
                $this->issues->insert($issue);
                $this->scanIssues->insert($scanId, $issue);
            }

            // Accurate counts by code.
            $counts = $this->scanIssues->countsByCode($scanId);
            $issuesTotal = 0;
            foreach ($counts as $c) {
                $issuesTotal += (int) $c;
            }

            $finishedAtTs = time();
            $summary = [
                'scan_id'       => $scanId,
                'started_at'    => $startedAtTs,
                'finished_at'   => $finishedAtTs,
                'duration_sec'  => max(0, $finishedAtTs - $startedAtTs),
                'total_posts'   => isset($scan['total_posts']) ? (int) $scan['total_posts'] : 0,
                'total_terms'   => isset($scan['total_terms']) ? (int) $scan['total_terms'] : 0,
                'scanned_posts' => isset($scan['scanned_posts']) ? (int) $scan['scanned_posts'] : 0,
                'scanned_terms' => isset($scan['scanned_terms']) ? (int) $scan['scanned_terms'] : 0,
                'news_eligible' => isset($scan['news_eligible']) ? (int) $scan['news_eligible'] : 0,
                'video_candidates' => isset($scan['video_candidates']) ? (int) $scan['video_candidates'] : 0,
                'video_eligible' => isset($scan['video_eligible']) ? (int) $scan['video_eligible'] : 0,
                'issues_total'  => $issuesTotal,
                'issues_by_code' => $counts,
            ];

            update_option('wpsn_audit_last_scan', $summary, false);

            $this->scans->finishScan($scanId, $summary, 'completed');

            // Retention: keep last N scans.
            $keep = (int) $this->settings->get('audit.history_keep_scans', 20);
            if ($keep < 0) {
                $keep = 0;
            }
            if ($keep > 500) {
                $keep = 500;
            }
            if ($keep > 0) {
                $this->scans->purgeKeepLast($keep);
            }

            $this->logger->info('Audit scan completed (async)', $summary);

            /**
             * Fired when an audit scan is completed.
             *
             * @param int $scanId
             * @param array<string,mixed> $summary
             */
            do_action('wpsn/audit/scan_completed', (int) $scanId, $summary);

            return null;
        } catch (\Throwable $e) {
            $this->scans->markFailed($scanId, $e->getMessage());
            $this->logger->error('Audit finalize failed: ' . $e->getMessage());
            throw $e;
        }
    }

    /**
     * @param array<int,array{object_type:string,key:string,rows:array<int,array{object_id:int,object_subtype:string}>}> $groups
     */
    private function persistDuplicateIssueGroups(
        int $scanId,
        array $groups,
        string $issueCode,
        int $severity,
        string $messageTemplate,
        string $suggestion,
        string $detailKey
    ): void {
        foreach ($groups as $group) {
            if (!is_array($group)) {
                continue;
            }

            $objectType = isset($group['object_type']) ? sanitize_key((string) $group['object_type']) : '';
            $key = isset($group['key']) ? (string) $group['key'] : '';
            $rows = isset($group['rows']) && is_array($group['rows']) ? $group['rows'] : [];
            if ($objectType === '' || $key === '' || count($rows) < 2) {
                continue;
            }

            $count = count($rows);
            $allIds = [];
            $allSubTypes = [];
            foreach ($rows as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $objectId = isset($row['object_id']) ? (int) $row['object_id'] : 0;
                $objectSubType = isset($row['object_subtype']) ? sanitize_key((string) $row['object_subtype']) : '';
                if ($objectId <= 0) {
                    continue;
                }
                $allIds[] = $objectId;
                if ($objectSubType !== '') {
                    $allSubTypes[$objectSubType] = true;
                }
            }

            if (count($allIds) < 2) {
                continue;
            }

            foreach ($rows as $row) {
                if (!is_array($row)) {
                    continue;
                }

                $objectId = isset($row['object_id']) ? (int) $row['object_id'] : 0;
                $objectSubType = isset($row['object_subtype']) ? sanitize_key((string) $row['object_subtype']) : '';
                if ($objectId <= 0) {
                    continue;
                }

                $ref = $this->resolveDuplicateObjectRef($objectType, $objectId, $objectSubType);
                $issueRow = [
                    'object_type' => $objectType,
                    'object_id' => $objectId,
                    'object_subtype' => (string) ($ref['object_subtype'] ?? $objectSubType),
                    'object_title' => (string) ($ref['object_title'] ?? ''),
                    'object_url' => (string) ($ref['object_url'] ?? ''),
                    'issue_code' => $issueCode,
                    'severity' => $severity,
                    'message' => sprintf(
                        /* translators: %d is number of pages */
                        $messageTemplate,
                        $count
                    ),
                    'suggestion' => $suggestion,
                    'details' => wp_json_encode([
                        'group_size' => $count,
                        $detailKey => $key,
                        'all_ids' => array_values(array_map('intval', $allIds)),
                        'all_subtypes' => array_values(array_keys($allSubTypes)),
                    ]),
                    'detected_at' => current_time('mysql'),
                    'updated_at' => current_time('mysql'),
                ];

                $this->issues->insert($issueRow);
                $this->scanIssues->insert($scanId, $issueRow);
            }
        }
    }

    /**
     * @return array{object_subtype:string,object_title:string,object_url:string}
     */
    private function resolveDuplicateObjectRef(string $objectType, int $objectId, string $objectSubType): array
    {
        $objectType = sanitize_key($objectType);
        $objectId = (int) $objectId;
        $objectSubType = sanitize_key($objectSubType);

        if ($objectType === 'term') {
            $term = $objectSubType !== '' ? get_term($objectId, $objectSubType) : get_term($objectId);
            $title = '';
            if ($term && !is_wp_error($term)) {
                $title = isset($term->name) ? (string) $term->name : '';
                $objectSubType = isset($term->taxonomy) ? sanitize_key((string) $term->taxonomy) : $objectSubType;
            }

            $url = '';
            if ($objectSubType !== '') {
                $url = get_term_link($objectId, $objectSubType);
                if (is_wp_error($url) || !is_string($url)) {
                    $url = '';
                }
            }

            return [
                'object_subtype' => $objectSubType,
                'object_title' => $title,
                'object_url' => $url,
            ];
        }

        $url = get_permalink($objectId);
        if (!is_string($url)) {
            $url = '';
        }

        $postType = (string) get_post_type($objectId);
        if ($postType !== '') {
            $objectSubType = sanitize_key($postType);
        }

        return [
            'object_subtype' => $objectSubType,
            'object_title' => (string) get_the_title($objectId),
            'object_url' => $url,
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function buildFrozenConfig(): array
    {
        // Mirror AuditRunner's scan-level priming.
        $newsEnabled = (bool) $this->settings->get('sitemap.news.enabled', false);
        $newsPostTypes = [];
        $newsSinceTs = 0;

        if ($newsEnabled) {
            $raw = $this->settings->get('sitemap.news.post_types', []);
            if (is_array($raw)) {
                foreach ($raw as $pt => $on) {
                    if (!empty($on)) {
                        $newsPostTypes[] = (string) $pt;
                    }
                }
            }
            if ($newsPostTypes === []) {
                $newsPostTypes = ['post'];
            }
            $newsSinceTs = time() - (48 * HOUR_IN_SECONDS);
        }

        $videoEnabled = (bool) $this->settings->get('sitemap.video.enabled', false);
        $videoMode = 'meta';
        $videoIncludeFeatured = true;
        if ($videoEnabled) {
            $videoMode = (string) $this->settings->get('sitemap.video.mode', 'meta');
            $videoIncludeFeatured = (bool) $this->settings->get('sitemap.video.include_featured_thumbnail', true);
        }

        return [
            'news_sitemap_enabled' => $newsEnabled ? 1 : 0,
            'news_post_types' => $newsPostTypes,
            'news_since_ts' => (int) $newsSinceTs,
            'video_sitemap_enabled' => $videoEnabled ? 1 : 0,
            'video_mode' => sanitize_key($videoMode) ?: 'meta',
            'video_include_featured_thumbnail' => $videoIncludeFeatured ? 1 : 0,
        ];
    }
}
