<?php

namespace WPSN\Modules\Audit\Admin;

use WPSN\Admin\SettingsRepository;
use WPSN\Core\Logger;
use WPSN\Modules\Audit\AuditInstaller;
use WPSN\Modules\Audit\AuditIssueRepository;
use WPSN\Modules\Audit\AuditJobs;
use WPSN\Modules\Audit\AuditAutomation;
use WPSN\Modules\Audit\AuditScanIssueRepository;
use WPSN\Modules\Audit\AuditScanRepository;
use WPSN\Modules\Audit\AuditRunner;
use WPSN\Modules\Audit\AuditSqlScanner;
use WPSN\Modules\Bulk\BulkQueueSessionRepository;
use WPSN\Modules\Bulk\BulkSessionReportRepository;
use WPSN\Modules\Diagnostics\Admin\QueueAdmin;
use WPSN\Modules\Diagnostics\QueueLiveRunnerProfiles;
use WPSN\Core\Capabilities;
use WPSN\Core\Queue\QueuePause;
use WPSN\Core\Queue\QueueRunner;

defined('ABSPATH') || exit;

final class AuditAdmin
{
    private AuditIssueRepository $issues;
    private AuditScanRepository $scans;
    private AuditScanIssueRepository $scanIssues;
    private AuditJobs $jobs;
    private AuditSqlScanner $sqlScanner;
    private QueueRunner $queueRunner;
    private Logger $logger;

    private string $hookSuffix = '';

    private const AJAX_NONCE_ACTION = 'wpsn_audit_ajax';

    /**
     * Small internal “pump” to progress Audit scans without hammering admin-ajax.
     *
     * On some shared hosts frequent AJAX worker calls trigger 503 (rate limits / CPU limits).
     * Instead we do best-effort background processing after the response has been sent.
     */
    private const PUMP_LOCK_TRANSIENT = 'wpsn_audit_pump_lock';

    /**
     * Execute an AJAX handler while capturing any stray output (PHP notices/warnings),
     * so JSON responses remain valid even when WP_DEBUG_DISPLAY is enabled.
     *
     * @param callable():array<string,mixed> $fn
     */
    private function ajaxSafe(callable $fn): void
    {
        $level = ob_get_level();
        ob_start();

        try {
            $data = $fn();

            $out = $this->collectAjaxOutput($level);
            if ($out !== '') {
                $this->logger->error('Audit AJAX produced unexpected output', [
                    'output' => $this->compactAjaxOutput($out),
                ]);
            }

            wp_send_json_success($data);
        } catch (\Throwable $e) {
            $out = $this->collectAjaxOutput($level);

            $status = (int) $e->getCode();
            if ($status < 400 || $status > 599) {
                $status = 500;
            }

            $payload = [
                'message' => $e->getMessage(),
            ];

            if ($out !== '') {
                $payload['debug_output'] = $this->compactAjaxOutput($out);
                $payload['hint'] = __('Non-JSON output was detected (PHP notices/warnings can break AJAX). Check WP_DEBUG_DISPLAY or other plugins printing output.', 'wp-seo-next');
                $this->logger->error('Audit AJAX produced unexpected output (error)', [
                    'status' => $status,
                    'message' => $e->getMessage(),
                    'output' => $this->compactAjaxOutput($out),
                ]);
            } else {
                $this->logger->error('Audit AJAX handler error: ' . $e->getMessage());
            }

            wp_send_json_error($payload, $status);
        }
    }

    private function collectAjaxOutput(int $startLevel): string
    {
        $out = '';
        while (ob_get_level() > $startLevel) {
            $out .= (string) ob_get_clean();
        }
        return $out;
    }

    private function compactAjaxOutput(string $out, int $limit = 2000): string
    {
        $out = wp_strip_all_tags($out);
        $out = preg_replace('/\s+/', ' ', $out) ?: $out;
        $out = trim($out);

        if ($out === '') {
            return '';
        }

        if (function_exists('mb_substr') && function_exists('mb_strlen')) {
            if (mb_strlen($out) > $limit) {
                $out = mb_substr($out, 0, $limit) . '…';
            }
        } else {
            if (strlen($out) > $limit) {
                $out = substr($out, 0, $limit) . '…';
            }
        }

        return $out;
    }

    /**
     * Schedule a lightweight Audit “pump” on shutdown.
     *
     * The pump runs only Audit job types and is intentionally capped to keep the
     * request responsive. If fastcgi_finish_request() is available, the response
     * is flushed before the pump starts.
     */
    private function scheduleShutdownPump(int $maxJobs = 1): void
    {
        // Respect operational pause.
        if (QueuePause::isPaused()) {
            return;
        }

        $maxJobs = max(1, min(3, (int) $maxJobs));

        // Avoid multiple pumps in parallel.
        if (get_transient(self::PUMP_LOCK_TRANSIENT)) {
            return;
        }
        set_transient(self::PUMP_LOCK_TRANSIENT, (string) time(), 15);

        static $scheduled = false;
        if ($scheduled) {
            return;
        }
        $scheduled = true;

        $runner = $this->queueRunner;
        $logger = $this->logger;

        add_action('shutdown', static function () use ($runner, $logger, $maxJobs): void {
            // Flush response early when possible.
            if (function_exists('fastcgi_finish_request')) {
                @fastcgi_finish_request();
            }

            try {
                $runner->runOnlyTypes([
                    AuditJobs::JOB_SCAN_DISPATCH,
                    AuditJobs::JOB_SCAN_POSTS,
                    AuditJobs::JOB_SCAN_TERMS,
                    AuditJobs::JOB_SCAN_FINALIZE,
                ], $maxJobs);
            } catch (\Throwable $e) {
                $logger->error('Audit shutdown pump failed: ' . $e->getMessage());
            }
        }, 0);
    }

    public static function normalizeManualScanEngine(string $engine = '', string $legacyScanMode = ''): string
    {
        $engine = sanitize_key($engine);
        $legacyScanMode = sanitize_key($legacyScanMode);

        if ($engine === '' && $legacyScanMode === 'sql') {
            $engine = 'sql';
        }

        if (!in_array($engine, ['async', 'sql'], true)) {
            $engine = 'async';
        }

        return $engine;
    }

    public static function normalizeAsyncScanMode(string $scanMode = ''): string
    {
        $scanMode = sanitize_key($scanMode);
        if (!in_array($scanMode, ['normal', 'light', 'fast'], true)) {
            $scanMode = 'normal';
        }

        return $scanMode;
    }

    /**
     * @return array<string,mixed>
     */
    public static function asyncScanOptionsForMode(string $scanMode): array
    {
        $scanMode = self::normalizeAsyncScanMode($scanMode);
        $opts = [];

        if ($scanMode === 'light') {
            $opts['mode'] = 'light';
        } elseif ($scanMode === 'fast') {
            $opts['posts_per_batch'] = 100;
            $opts['terms_per_batch'] = 200;
            $opts['dispatch_limit'] = 50;
            $opts['dispatch_delay'] = 1;
        }

        return $opts;
    }

    /**
     * @return array{scan_id:int,notice:string,error:string}
     */
    private function runManualScan(bool $includeTerms, bool $checkSocial, string $engine, string $scanMode = 'normal', bool $ultraFast = false, string $logContext = 'manual'): array
    {
        $engine = self::normalizeManualScanEngine($engine, '');

        if ($engine === 'sql') {
            $options = [];
            if ($ultraFast) {
                $options['ultra_fast'] = true;
                $checkSocial = false;
            }

            $running = $this->scans->latestRunning();
            if (is_array($running) && !empty($running['id'])) {
                try {
                    $this->jobs->cancelScan((int) $running['id'], __('Cancelled due to SQL scan', 'wp-seo-next'));
                } catch (\Throwable $e) {
                    $this->logger->error('Audit cancel before SQL scan failed: ' . $e->getMessage(), [
                        'context' => $logContext,
                    ]);
                }
            }

            try {
                $summary = $this->sqlScanner->run($includeTerms, $checkSocial, $options);
                return [
                    'scan_id' => (int) ($summary['scan_id'] ?? 0),
                    'notice' => 'audit_sql_scanned',
                    'error' => '',
                ];
            } catch (\Throwable $e) {
                $this->logger->error('Audit SQL scan failed: ' . $e->getMessage(), [
                    'context' => $logContext,
                ]);
                return [
                    'scan_id' => 0,
                    'notice' => 'audit_scan_failed',
                    'error' => $e->getMessage(),
                ];
            }
        }

        try {
            $res = $this->jobs->scheduleScan($includeTerms, $checkSocial, self::asyncScanOptionsForMode($scanMode));
            $scanId = (int) ($res['scan_id'] ?? 0);
            if ($scanId > 0) {
                $this->scheduleShutdownPump(1);
            }

            return [
                'scan_id' => $scanId,
                'notice' => !empty($res['already_running']) ? 'audit_scan_running' : 'audit_scan_queued',
                'error' => '',
            ];
        } catch (\Throwable $e) {
            $this->logger->error('Audit async scan failed: ' . $e->getMessage(), [
                'context' => $logContext,
            ]);
            return [
                'scan_id' => 0,
                'notice' => 'audit_scan_failed',
                'error' => $e->getMessage(),
            ];
        }
    }


    public function __construct(
        AuditIssueRepository $issues,
        AuditScanRepository $scans,
        AuditScanIssueRepository $scanIssues,
        AuditJobs $jobs,
        AuditSqlScanner $sqlScanner,
        QueueRunner $queueRunner,
        Logger $logger
    )
    {
        $this->issues = $issues;
        $this->scans = $scans;
        $this->scanIssues = $scanIssues;
        $this->jobs = $jobs;
        $this->sqlScanner = $sqlScanner;
        $this->queueRunner = $queueRunner;
        $this->logger = $logger;
    }

    public function register(): void
    {
        add_action('admin_menu', [$this, 'addMenu']);

        add_action('admin_post_wpsn_audit_scan', [$this, 'handleScan']);
        add_action('admin_post_wpsn_audit_clear', [$this, 'handleClear']);
        add_action('admin_post_wpsn_audit_clear_history', [$this, 'handleClearHistory']);
        add_action('admin_post_wpsn_audit_cancel', [$this, 'handleCancel']);
        add_action('admin_post_wpsn_audit_export_csv', [$this, 'handleExportCsv']);
        add_action('admin_post_wpsn_audit_export_json', [$this, 'handleExportJson']);
        add_action('admin_post_wpsn_audit_restore_scan', [$this, 'handleRestoreScan']);
        add_action('admin_post_wpsn_audit_bulk_action', [$this, 'handleBulkAction']);
        add_action('admin_post_wpsn_audit_bulk_selected_action', [$this, 'handleBulkSelectedAction']);
        add_action('admin_post_wpsn_audit_quick_action', [$this, 'handleQuickAction']);

        // Convenience GET action (used by Tasks Hub / dashboards).
        add_action('admin_post_wpsn_audit_scan_now', [$this, 'handleScanNow']);

        // Automation settings (daily/weekly scheduled scans).
        add_action('admin_post_wpsn_audit_save_automation', [$this, 'handleSaveAutomation']);

        add_action('wp_ajax_wpsn_audit_scan_start', [$this, 'ajaxScanStart']);
        add_action('wp_ajax_wpsn_audit_scan_status', [$this, 'ajaxScanStatus']);
        add_action('wp_ajax_wpsn_audit_scan_step', [$this, 'ajaxScanStep']);
        add_action('wp_ajax_wpsn_audit_scan_cancel', [$this, 'ajaxScanCancel']);

        add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);

        add_action('admin_notices', [$this, 'adminNotices']);

        // Ensure table exists.
        add_action('admin_init', [AuditInstaller::class, 'maybeInstall'], 1);
    }

    public function addMenu(): void
    {
        $this->hookSuffix = (string) add_management_page(
            __('SEO Audit', 'wp-seo-next'),
            __('SEO Audit', 'wp-seo-next'),
            Capabilities::CAP_TOOLS,
            'wpsn-audit',
            [$this, 'renderPage']
        );
    }

    public function enqueueAssets(string $hook): void
    {
        if ($this->hookSuffix !== '' && $hook !== $this->hookSuffix) {
            return;
        }

        // Fallback: match by page slug.
        if ($this->hookSuffix === '' && (!isset($_GET['page']) || (string) wp_unslash($_GET['page']) !== 'wpsn-audit')) {
            return;
        }

        wp_enqueue_script(
            'wpsn-audit',
            defined('WPSN_PLUGIN_URL') ? (WPSN_PLUGIN_URL . 'assets/admin/audit.js') : plugins_url('assets/admin/audit.js', defined('WPSN_PLUGIN_FILE') ? WPSN_PLUGIN_FILE : __FILE__),
            ['jquery'],
            defined('WPSN_VERSION') ? WPSN_VERSION : '0.0.0',
            true
        );

        wp_localize_script('wpsn-audit', 'WPSNAudit', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'nonce' => wp_create_nonce(self::AJAX_NONCE_ACTION),
            'pageUrl' => admin_url('tools.php?page=wpsn-audit'),
            'currentScanId' => isset($_GET['scan_id']) ? (int) $_GET['scan_id'] : 0,
            'sqlOnly' => false,
            'i18n' => [
                'cancel' => __('Cancel scan', 'wp-seo-next'),
                'cancelling' => __('Cancelling…', 'wp-seo-next'),
                'confirm_cancel' => __('Cancel this audit scan?', 'wp-seo-next'),
                'cancelled' => __('cancelled', 'wp-seo-next'),
                'pending_jobs_removed' => __('Cancelled. Pending jobs removed:', 'wp-seo-next'),
                'select_none' => __('Select at least one row first.', 'wp-seo-next'),
            ],
        ]);

        wp_enqueue_script(
            'wpsn-queue-live',
            defined('WPSN_PLUGIN_URL') ? (WPSN_PLUGIN_URL . 'assets/admin/queue-live.js') : plugins_url('assets/admin/queue-live.js', defined('WPSN_PLUGIN_FILE') ? WPSN_PLUGIN_FILE : __FILE__),
            [],
            defined('WPSN_VERSION') ? WPSN_VERSION : '0.0.0',
            true
        );

        wp_localize_script('wpsn-queue-live', 'WPSNQueueLive', [
            'endpointUrl' => admin_url('admin-post.php'),
            'nonce' => wp_create_nonce(QueueAdmin::LIVE_NONCE_ACTION),
            'defaultProfile' => QueueLiveRunnerProfiles::DEFAULT_PROFILE,
            'profiles' => QueueLiveRunnerProfiles::clientConfig(),
            'i18n' => [
                'running' => __('Live worker is running…', 'wp-seo-next'),
                'paused' => __('Live worker paused.', 'wp-seo-next'),
                'watching' => __('Watching progress only.', 'wp-seo-next'),
                'completed' => __('Queue session completed.', 'wp-seo-next'),
                'queuePaused' => __('Queue is paused — live worker stopped.', 'wp-seo-next'),
                'statusError' => __('Live update failed. Retrying shortly…', 'wp-seo-next'),
                'start' => __('Start live run', 'wp-seo-next'),
                'stop' => __('Pause live run', 'wp-seo-next'),
                'lastBatch' => __('Last batch: {processed} jobs in {elapsed} ms. Next update in {seconds}s.', 'wp-seo-next'),
                'nextStatus' => __('Next status check in {seconds}s.', 'wp-seo-next'),
            ],
        ]);
    }

    public function adminNotices(): void
    {
        if (empty($_GET['wpsn_notice'])) {
            return;
        }

        $notice = sanitize_key((string) wp_unslash($_GET['wpsn_notice']));
        $map = [
            'audit_scan_queued' => __('Audit scan started (accurate async mode). You can monitor progress on this page.', 'wp-seo-next'),
            'audit_scan_running' => __('Audit scan is already running. Showing the current scan progress.', 'wp-seo-next'),
            'audit_scanned' => __('Audit scan completed.', 'wp-seo-next'),
            'audit_sql_scanned' => __('Audit SQL scan completed (DB-only).', 'wp-seo-next'),
            'audit_scan_failed' => __('Audit scan failed.', 'wp-seo-next'),
            'audit_cancelled' => __('Audit scan cancelled.', 'wp-seo-next'),
            'audit_cleared' => __('Audit issues cleared.', 'wp-seo-next'),
            'audit_history_cleared' => __('Audit scan history cleared.', 'wp-seo-next'),
            'audit_scan_restored' => __('Saved scan snapshot restored to the current live issues view.', 'wp-seo-next'),
            'audit_bulk_queued' => __('Bulk action queued.', 'wp-seo-next'),
            'audit_automation_saved' => __('Audit automation settings saved.', 'wp-seo-next'),
        ];

        if (!isset($map[$notice])) {
            return;
        }

        $msg = $map[$notice];
        $class = 'notice-success';

        if ($notice === 'audit_scan_failed') {
            $class = 'notice-error';
            $err = isset($_GET['err']) ? sanitize_text_field((string) wp_unslash($_GET['err'])) : '';
            if ($err !== '') {
                $msg .= ' ' . $err;
            }
        }
        if ($notice === 'audit_scan_restored') {
            $msg .= ' ' . __('This only restores the saved issue snapshot for browsing/filtering. It does not change post or term SEO meta.', 'wp-seo-next');
        }
        $bulkSessionId = '';
        if ($notice === 'audit_bulk_queued') {
            $act = isset($_GET['bulk_action']) ? sanitize_key((string) wp_unslash($_GET['bulk_action'])) : '';
            $jobs = isset($_GET['bulk_jobs']) ? (int) $_GET['bulk_jobs'] : 0;
            $targets = isset($_GET['bulk_targets']) ? (int) $_GET['bulk_targets'] : 0;
            $eligible = isset($_GET['bulk_eligible']) ? (int) $_GET['bulk_eligible'] : 0;
            $skipped = isset($_GET['bulk_skipped']) ? (int) $_GET['bulk_skipped'] : 0;
            $bulkSessionId = isset($_GET['bulk_session']) ? sanitize_key((string) wp_unslash($_GET['bulk_session'])) : '';

            $labels = [
                'generate_local' => __('Generate local meta suggestions (draft)', 'wp-seo-next'),
                'generate_apply_local' => __('Generate + apply local meta suggestions', 'wp-seo-next'),
                'clear_canonical' => __('Clear canonical override', 'wp-seo-next'),
                'clear_noindex' => __('Clear noindex override', 'wp-seo-next'),
            ];
            $label = $act !== '' && isset($labels[$act]) ? $labels[$act] : $act;
            if (($act === 'generate_local' || $act === 'generate_apply_local') && $eligible <= 0 && $jobs > 0) {
                $eligible = $act === 'generate_apply_local' ? (int) floor($jobs / 2) : $jobs;
            }

            if ($eligible > 0 && ($act === 'generate_local' || $act === 'generate_apply_local')) {
                $msg = sprintf(
                    /* translators: 1: action label, 2: found objects, 3: eligible objects, 4: queued jobs */
                    __('Bulk action queued: %1$s — objects found: %2$d, eligible objects: %3$d, queued jobs: %4$d. Jobs run asynchronously in Queue Center.', 'wp-seo-next'),
                    $label,
                    $targets,
                    $eligible,
                    $jobs
                );
            } else {
                $msg = sprintf(
                    /* translators: 1: action label, 2: found objects, 3: queued jobs */
                    __('Bulk action queued: %1$s — objects found: %2$d, queued jobs: %3$d. Jobs run asynchronously in Queue Center.', 'wp-seo-next'),
                    $label,
                    $targets,
                    $jobs
                );
            }
            if ($skipped > 0) {
                $msg .= ' ' . sprintf(
                    /* translators: %d skipped unsupported objects */
                    __('(skipped unsupported objects: %d)', 'wp-seo-next'),
                    $skipped
                );
            }
            if (in_array($act, ['generate_local', 'generate_apply_local', 'clear_canonical', 'clear_noindex'], true)) {
                $msg .= ' ' . __('Per-object rows now refresh automatically after queued fixes apply, and the latest Audit / Issues totals are re-synced in the background.', 'wp-seo-next');
            }
            if ($bulkSessionId !== '') {
                $msg .= ' ' . sprintf(__('Session: %s. Open Queue Center for live progress.', 'wp-seo-next'), $bulkSessionId);
            }
        }

        echo '<div class="notice ' . esc_attr($class) . ' is-dismissible"><p>' . esc_html($msg) . '</p></div>';

        if ($bulkSessionId !== '') {
            $this->renderBulkSessionCard($bulkSessionId);
        }
    }

    private function renderBulkSessionCard(string $sessionId): void
    {
        $sessionId = sanitize_key($sessionId);
        if ($sessionId === '') {
            return;
        }

        $repo = new BulkQueueSessionRepository($this->logger);
        $session = $repo->get($sessionId);
        $progress = $repo->progress($sessionId);
        if (empty($session) || empty($progress)) {
            return;
        }

        $queueUrl = add_query_arg([
            'page' => 'wpsn-queue',
            'bulk_session' => $sessionId,
        ], admin_url('tools.php'));
        $reportUrl = $queueUrl . '#wpsn-bulk-session-report';
        $downloadUrl = wp_nonce_url(
            admin_url('admin-post.php?action=wpsn_bulk_session_export&session_id=' . rawurlencode($sessionId) . '&format=json'),
            'wpsn_bulk_session_export_' . $sessionId,
            'wpsn_nonce'
        );
        $returnUrl = isset($session['return_url']) ? esc_url_raw((string) $session['return_url']) : '';
        $percent = isset($progress['percent']) ? (int) $progress['percent'] : 0;
        $terminal = isset($progress['terminal']) ? (int) $progress['terminal'] : 0;
        $queuedJobs = isset($progress['queued_jobs']) ? (int) $progress['queued_jobs'] : 0;
        $preferredProfile = QueueLiveRunnerProfiles::normalize((string) ($session['preferred_profile'] ?? QueueLiveRunnerProfiles::DEFAULT_PROFILE));
        $defaultProfile = QueueLiveRunnerProfiles::definition($preferredProfile);
        $settings = (new SettingsRepository())->getAll();
        $queueMode = sanitize_key((string) ($settings['performance']['queue']['mode'] ?? 'direct'));
        $directMaxJobs = max(1, (int) ($settings['performance']['queue']['direct']['max_jobs'] ?? 200));
        $targets = isset($session['targets']) ? (int) $session['targets'] : 0;
        $reportRepo = new BulkSessionReportRepository();
        $reportSummary = $reportRepo->summary($sessionId);
        $reportMeta = $reportRepo->get($sessionId);
        $reportMeta = isset($reportMeta['meta']) && is_array($reportMeta['meta']) ? $reportMeta['meta'] : [];
        $liveDelta = $this->bulkLiveDeltaContext($reportMeta, $reportSummary);

        echo '<div class="wpsn-queue-live" data-wpsn-queue-live="1" data-session-id="' . esc_attr($sessionId) . '" data-auto-start="1" data-default-profile="' . esc_attr($preferredProfile) . '" style="margin-top:10px; padding:12px; background:#fff; border:1px solid #dcdcde; border-radius:6px;">';
        echo '<div style="display:flex; gap:12px; align-items:flex-start; justify-content:space-between; flex-wrap:wrap;">';
        echo '<div>';
        echo '<strong>' . esc_html__('Bulk fix session', 'wp-seo-next') . ':</strong> <code>' . esc_html($sessionId) . '</code><br/>';
        echo '<span class="description">' . esc_html($repo->actionLabel((string) ($session['action'] ?? ''))) . '</span>';
        echo '</div>';
        echo '<div>';
        echo '<a class="button button-secondary" href="' . esc_url($queueUrl) . '">' . esc_html__('Open Queue Center', 'wp-seo-next') . '</a>';
        echo ' <a class="button" href="' . esc_url($reportUrl) . '">' . esc_html__('View change report', 'wp-seo-next') . '</a>';
        echo ' <a class="button" href="' . esc_url($downloadUrl) . '">' . esc_html__('Download JSON', 'wp-seo-next') . '</a>';
        if ($returnUrl !== '') {
            echo ' <a class="button" href="' . esc_url($returnUrl) . '">' . esc_html__('Back to bulk scope', 'wp-seo-next') . '</a>';
        }
        echo '</div>';
        echo '</div>';

        echo '<div style="margin-top:10px; display:flex; gap:10px; align-items:center; flex-wrap:wrap;">';
        echo '<label><strong>' . esc_html__('Live worker', 'wp-seo-next') . ':</strong> <select class="wpsn-queue-live-profile">';
        foreach (QueueLiveRunnerProfiles::definitions() as $profileKey => $profileDef) {
            echo '<option value="' . esc_attr((string) $profileKey) . '"' . selected($preferredProfile, (string) $profileKey, false) . '>' . esc_html((string) ($profileDef['label'] ?? $profileKey)) . '</option>';
        }
        echo '</select></label>';
        echo ' <button type="button" class="button button-secondary wpsn-queue-live-toggle">' . esc_html__('Pause live run', 'wp-seo-next') . '</button>';
        echo '<span class="description wpsn-queue-live-profile-desc">' . esc_html((string) ($defaultProfile['description'] ?? '')) . '</span>';
        echo '</div>';

        echo '<div style="margin-top:10px; height:10px; background:#f0f0f1; border-radius:999px; overflow:hidden;">';
        echo '<div class="wpsn-queue-live-progress-bar" style="height:10px; width:' . esc_attr((string) max(0, min(100, $percent))) . '%; background:#2271b1;"></div>';
        echo '</div>';
        echo '<p class="description wpsn-queue-live-stats" style="margin:8px 0 0 0;">' . esc_html(sprintf(__('Processed %1$d of %2$d queued jobs · objects %3$d · pending %4$d · running %5$d · failed %6$d', 'wp-seo-next'), $terminal, $queuedJobs, $targets, (int) ($progress['pending'] ?? 0), (int) ($progress['running'] ?? 0), (int) ($progress['failed'] ?? 0))) . '</p>';
        echo '<p class="description" style="margin:6px 0 0 0;">' . esc_html(sprintf(__('Generator summary: generated %1$d · meta updated %2$d · no change %3$d · failed %4$d', 'wp-seo-next'), (int) ($reportSummary['generated'] ?? 0), (int) ($reportSummary['applied'] ?? 0), (int) ($reportSummary['apply_no_change'] ?? 0), (int) (($reportSummary['generate_failed'] ?? 0) + ($reportSummary['apply_failed'] ?? 0) + ($reportSummary['fix_failed'] ?? 0)))) . '</p>';
        if (!empty($liveDelta['label'])) {
            echo '<p class="description" style="margin:6px 0 0 0;">' . esc_html((string) $liveDelta['label']) . '</p>';
            if (!empty($liveDelta['details'])) {
                echo '<p class="description" style="margin:4px 0 0 0;">' . esc_html((string) $liveDelta['details']) . '</p>';
            }
        }
        echo '<p class="description wpsn-queue-live-status" style="margin:6px 0 0 0;">' . esc_html__('Live worker will keep this queue moving while this page stays open.', 'wp-seo-next') . '</p>';
        if ($queueMode === 'direct') {
            echo '<p class="description" style="margin:6px 0 0 0;">' . esc_html(sprintf(__('Direct queue mode currently auto-runs up to %d jobs in the request that queued this action. The rest continue via the live worker while this page stays open.', 'wp-seo-next'), $directMaxJobs)) . '</p>';
        }
        echo '</div>';
    }

    private function renderRecentBulkReports(): void
    {
        $sessionRepo = new BulkQueueSessionRepository($this->logger);
        $reportRepo = new BulkSessionReportRepository();
        $sessions = $sessionRepo->listRecent(10);
        $rows = [];

        foreach ($sessions as $session) {
            if (!is_array($session)) {
                continue;
            }
            $source = isset($session['source']) ? sanitize_key((string) $session['source']) : '';
            if ($source !== 'audit') {
                continue;
            }

            $sessionId = isset($session['session_id']) ? sanitize_key((string) $session['session_id']) : '';
            if ($sessionId === '') {
                continue;
            }

            $summary = $reportRepo->summary($sessionId);
            $progress = $sessionRepo->progress($sessionId);
            $report = $reportRepo->get($sessionId);
            $rows[] = [
                'session' => $session,
                'summary' => $summary,
                'progress' => $progress,
                'meta' => isset($report['meta']) && is_array($report['meta']) ? $report['meta'] : [],
            ];
        }

        if (empty($rows)) {
            return;
        }

        echo '<div class="wpsn-card" style="margin:12px 0; max-width:1200px;">';
        echo '<div style="display:flex; gap:12px; align-items:flex-start; justify-content:space-between; flex-wrap:wrap;">';
        echo '<div>';
        echo '<h2 style="margin:0 0 6px 0;">' . esc_html__('Recent bulk / change reports', 'wp-seo-next') . '</h2>';
        echo '<p class="description" style="margin:0;">' . esc_html__('Recent queued bulk runs are kept with their own timestamps and downloadable reports, so the evidence does not disappear after the issue list refreshes.', 'wp-seo-next') . '</p>';
        echo '</div>';
        echo '<div><a class="button button-secondary" href="' . esc_url(admin_url('tools.php?page=wpsn-revisions&source=bulk')) . '">' . esc_html__('Open Revisions', 'wp-seo-next') . '</a></div>';
        echo '</div>';

        echo '<table class="widefat striped" style="margin-top:12px;">';
        echo '<thead><tr>';
        echo '<th style="width:160px;">' . esc_html__('Started', 'wp-seo-next') . '</th>';
        echo '<th style="width:160px;">' . esc_html__('Updated', 'wp-seo-next') . '</th>';
        echo '<th style="width:170px;">' . esc_html__('Session', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Action', 'wp-seo-next') . '</th>';
        echo '<th style="width:170px;">' . esc_html__('Scope', 'wp-seo-next') . '</th>';
        echo '<th style="width:220px;">' . esc_html__('Result', 'wp-seo-next') . '</th>';
        echo '<th style="width:220px;">' . esc_html__('Actions', 'wp-seo-next') . '</th>';
        echo '</tr></thead><tbody>';

        foreach ($rows as $row) {
            $session = $row['session'];
            $summary = $row['summary'];
            $progress = $row['progress'];
            $reportMeta = isset($row['meta']) && is_array($row['meta']) ? $row['meta'] : [];

            $sessionId = isset($session['session_id']) ? sanitize_key((string) $session['session_id']) : '';
            if ($sessionId === '') {
                continue;
            }

            $createdAt = isset($session['created_at']) ? (int) $session['created_at'] : 0;
            $updatedAt = isset($session['updated_at']) ? (int) $session['updated_at'] : 0;
            $scanId = isset($session['scan_id']) ? (int) $session['scan_id'] : 0;
            $targets = isset($session['targets']) ? (int) $session['targets'] : 0;
            $eligibleTargets = isset($session['eligible_targets']) ? (int) $session['eligible_targets'] : 0;
            $queuedJobs = isset($session['queued_jobs']) ? (int) $session['queued_jobs'] : 0;
            $failedTotal = (int) (($summary['generate_failed'] ?? 0) + ($summary['apply_failed'] ?? 0) + ($summary['fix_failed'] ?? 0));
            $queueUrl = add_query_arg([
                'page' => 'wpsn-queue',
                'bulk_session' => $sessionId,
            ], admin_url('tools.php')) . '#wpsn-bulk-session-report';
            $jsonUrl = wp_nonce_url(
                admin_url('admin-post.php?action=wpsn_bulk_session_export&session_id=' . rawurlencode($sessionId) . '&format=json'),
                'wpsn_bulk_session_export_' . $sessionId,
                'wpsn_nonce'
            );
            $csvUrl = wp_nonce_url(
                admin_url('admin-post.php?action=wpsn_bulk_session_export&session_id=' . rawurlencode($sessionId) . '&format=csv'),
                'wpsn_bulk_session_export_' . $sessionId,
                'wpsn_nonce'
            );
            $returnUrl = isset($session['return_url']) ? esc_url_raw((string) $session['return_url']) : '';

            echo '<tr>';
            echo '<td>' . esc_html($createdAt > 0 ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $createdAt) : '—') . '</td>';
            echo '<td>' . esc_html($updatedAt > 0 ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $updatedAt) : '—') . '</td>';
            echo '<td><code>' . esc_html($sessionId) . '</code></td>';
            echo '<td>' . esc_html($sessionRepo->actionLabel((string) ($session['action'] ?? ''))) . '</td>';
            if ($scanId > 0) {
                echo '<td>' . esc_html(sprintf(__('scan #%1$d · %2$d/%3$d objects · %4$d jobs', 'wp-seo-next'), $scanId, $eligibleTargets, $targets, $queuedJobs));
            } else {
                echo '<td>' . esc_html(sprintf(__('live issues · %1$d/%2$d objects · %3$d jobs', 'wp-seo-next'), $eligibleTargets, $targets, $queuedJobs));
            }
            echo '</td>';
            echo '<td>' . esc_html(sprintf(__('generated %1$d · meta updated %2$d · no change %3$d · failed %4$d', 'wp-seo-next'), (int) ($summary['generated'] ?? 0), (int) ($summary['applied'] ?? 0), (int) ($summary['apply_no_change'] ?? 0), $failedTotal));
            $liveDelta = $this->bulkLiveDeltaContext($reportMeta, $summary);
            if (!empty($liveDelta['label'])) {
                echo '<br/><span class="description">' . esc_html((string) $liveDelta['label']) . '</span>';
                if (!empty($liveDelta['details'])) {
                    echo '<br/><span class="description">' . esc_html((string) $liveDelta['details']) . '</span>';
                }
            }
            if (!empty($progress)) {
                echo '<br/><span class="description">' . esc_html(sprintf(__('queue %1$d%% · pending %2$d · running %3$d', 'wp-seo-next'), (int) ($progress['percent'] ?? 0), (int) ($progress['pending'] ?? 0), (int) ($progress['running'] ?? 0))) . '</span>';
            }
            echo '</td>';
            echo '<td><a class="button button-small" href="' . esc_url($queueUrl) . '">' . esc_html__('View report', 'wp-seo-next') . '</a>';
            echo ' <a class="button button-small" href="' . esc_url($jsonUrl) . '">' . esc_html__('JSON', 'wp-seo-next') . '</a>';
            echo ' <a class="button button-small" href="' . esc_url($csvUrl) . '">' . esc_html__('CSV', 'wp-seo-next') . '</a>';
            if ($returnUrl !== '') {
                echo ' <a class="button button-small" href="' . esc_url($returnUrl) . '">' . esc_html__('Scope', 'wp-seo-next') . '</a>';
            }
            echo '</td>';
            echo '</tr>';
        }

        echo '</tbody></table>';
        echo '<p class="description" style="margin:8px 0 0 0;">' . esc_html__('The audit page keeps recent queued sessions by date so you can export the exact JSON/CSV later and compare runs. History now retains more sessions before trimming old ones.', 'wp-seo-next') . '</p>';
        echo '</div>';
    }

    public function handleScan(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_audit_scan')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $includeTerms = !empty($_POST['include_terms']);
        $checkSocial  = !empty($_POST['check_social']);
        $engine = self::normalizeManualScanEngine(
            isset($_POST['scan_engine']) ? (string) wp_unslash($_POST['scan_engine']) : '',
            isset($_POST['scan_mode']) ? (string) wp_unslash($_POST['scan_mode']) : ''
        );
        $scanMode = self::normalizeAsyncScanMode(isset($_POST['scan_mode']) ? (string) wp_unslash($_POST['scan_mode']) : 'normal');
        $ultraFast = ($engine === 'sql') && !empty($_POST['ultra_fast']);

        $result = $this->runManualScan($includeTerms, $checkSocial, $engine, $scanMode, $ultraFast, 'handleScan');

        $redir = admin_url('tools.php?page=wpsn-audit&wpsn_notice=' . sanitize_key((string) ($result['notice'] ?? 'audit_scan_failed')));
        if (!empty($result['scan_id'])) {
            $redir = add_query_arg(['scan_id' => (int) $result['scan_id']], $redir);
        }
        if (!empty($result['error'])) {
            $redir = add_query_arg(['err' => (string) $result['error']], $redir);
        }

        wp_safe_redirect($redir);
        exit;
    }

    /**
     * Convenience GET handler used by Tasks Hub / dashboards.
     *
     * Example URL:
     * admin-post.php?action=wpsn_audit_scan_now&_wpnonce=...&include_terms=1&check_social=1
     */
    public function handleScanNow(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_audit_scan_now');

        $includeTerms = !empty($_GET['include_terms']);
        $checkSocial  = !empty($_GET['check_social']);
        $engine = self::normalizeManualScanEngine(
            isset($_GET['scan_engine']) ? (string) wp_unslash($_GET['scan_engine']) : '',
            isset($_GET['scan_mode']) ? (string) wp_unslash($_GET['scan_mode']) : ''
        );
        $scanMode = self::normalizeAsyncScanMode(isset($_GET['scan_mode']) ? (string) wp_unslash($_GET['scan_mode']) : 'normal');
        $ultraFast = ($engine === 'sql') && !empty($_GET['ultra_fast']);

        $result = $this->runManualScan($includeTerms, $checkSocial, $engine, $scanMode, $ultraFast, 'handleScanNow');

        $redir = admin_url('tools.php?page=wpsn-audit&wpsn_notice=' . sanitize_key((string) ($result['notice'] ?? 'audit_scan_failed')));
        if (!empty($result['scan_id'])) {
            $redir = add_query_arg(['scan_id' => (int) $result['scan_id']], $redir);
        }
        if (!empty($result['error'])) {
            $redir = add_query_arg(['err' => (string) $result['error']], $redir);
        }

        wp_safe_redirect($redir);
        exit;
    }

    public function handleSaveAutomation(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_audit_save_automation', 'wpsn_nonce');

        $schedule = isset($_POST['schedule']) ? sanitize_key((string) wp_unslash($_POST['schedule'])) : 'off';
        if (!in_array($schedule, ['off', 'daily', 'weekly'], true)) {
            $schedule = 'off';
        }

        $hour = isset($_POST['hour_utc']) ? (int) $_POST['hour_utc'] : 4;
        $hour = max(0, min(23, $hour));

        $weekday = isset($_POST['weekday_utc']) ? (int) $_POST['weekday_utc'] : 1;
        $weekday = max(0, min(6, $weekday));

        $keepScans = isset($_POST['history_keep_scans']) ? (int) $_POST['history_keep_scans'] : 20;
        $keepScans = max(0, min(500, $keepScans));

        $includeTerms = !empty($_POST['include_terms']);
        $checkSocial  = !empty($_POST['check_social_image']);

        $smartWindow = !empty($_POST['smart_window']);
        $maxPending = isset($_POST['smart_window_max_pending']) ? (int) $_POST['smart_window_max_pending'] : 50;
        $maxPending = max(0, min(5000, $maxPending));

        $scanMode = isset($_POST['scan_mode']) ? sanitize_key((string) wp_unslash($_POST['scan_mode'])) : 'normal';
        if (!in_array($scanMode, ['normal', 'light', 'fast'], true)) {
            $scanMode = 'normal';
        }

        $repo = new SettingsRepository();
        $all = $repo->getAll();
        if (!isset($all['audit']) || !is_array($all['audit'])) {
            $all['audit'] = [];
        }

        $all['audit']['history_keep_scans'] = $keepScans;
        $all['audit']['automation'] = [
            'schedule' => $schedule,
            'hour_utc' => $hour,
            'weekday_utc' => $weekday,
            'include_terms' => (bool) $includeTerms,
            'check_social_image' => (bool) $checkSocial,
            'scan_mode' => $scanMode,
            'smart_window' => (bool) $smartWindow,
            'smart_window_max_pending' => $maxPending,
        ];
        $repo->update($all);

        wp_safe_redirect(admin_url('tools.php?page=wpsn-audit&wpsn_notice=audit_automation_saved'));
        exit;
    }

    public function handleClear(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_audit_clear')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $this->issues->clear();
        update_option('wpsn_audit_last_scan', [], false);

        wp_safe_redirect(admin_url('tools.php?page=wpsn-audit&wpsn_notice=audit_cleared'));
        exit;
    }

    public function handleClearHistory(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_audit_clear_history')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $this->scans->clearAll();
        update_option('wpsn_audit_last_scan', [], false);

        wp_safe_redirect(admin_url('tools.php?page=wpsn-audit&wpsn_notice=audit_history_cleared'));
        exit;
    }

    public function handleCancel(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_audit_cancel');

        $scanId = isset($_GET['scan_id']) ? (int) $_GET['scan_id'] : 0;
        if ($scanId <= 0) {
            wp_safe_redirect(admin_url('tools.php?page=wpsn-audit'));
            exit;
        }

        try {
            $this->jobs->cancelScan($scanId);
        } catch (\Throwable $e) {
            $this->logger->error('Audit cancel failed: ' . $e->getMessage());
        }

        $redir = add_query_arg(['scan_id' => $scanId, 'wpsn_notice' => 'audit_cancelled'], admin_url('tools.php?page=wpsn-audit'));
        wp_safe_redirect($redir);
        exit;
    }

    
public function ajaxScanStart(): void
    {
        $this->ajaxSafe(function (): array {
            if (!current_user_can(Capabilities::CAP_TOOLS)) {
                throw new \RuntimeException(__('Permission denied.', 'wp-seo-next'), 403);
            }

            $nonce = isset($_POST['nonce']) ? (string) wp_unslash($_POST['nonce']) : '';
            if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
                throw new \RuntimeException(__('Invalid nonce.', 'wp-seo-next'), 400);
            }

            $includeTerms = !empty($_POST['include_terms']);
            $checkSocial  = !empty($_POST['check_social']);

            $scanMode = isset($_POST['scan_mode']) ? sanitize_key((string) wp_unslash($_POST['scan_mode'])) : 'normal';
            if (!in_array($scanMode, ['normal', 'light', 'fast'], true)) {
                $scanMode = 'normal';
            }
            $opts = [];
            if ($scanMode === 'light') {
                $opts['mode'] = 'light';
            } elseif ($scanMode === 'fast') {
                $opts['posts_per_batch'] = 100;
                $opts['terms_per_batch'] = 200;
                $opts['dispatch_limit'] = 50;
                $opts['dispatch_delay'] = 1;
            }

            $res = $this->jobs->scheduleScan($includeTerms, $checkSocial, $opts);

            // Best-effort: progress the scan without requiring an aggressive AJAX worker loop.
            // This is critical on shared hosts where frequent admin-ajax calls can trigger 503.
            $scanId = (int) ($res['scan_id'] ?? 0);
            if ($scanId > 0) {
                $this->scheduleShutdownPump(1);
            }

            return $res;
        });
    }



    
public function ajaxScanCancel(): void
    {
        $this->ajaxSafe(function (): array {
            if (!current_user_can(Capabilities::CAP_TOOLS)) {
                throw new \RuntimeException(__('Permission denied.', 'wp-seo-next'), 403);
            }

            $nonce = isset($_POST['nonce']) ? (string) wp_unslash($_POST['nonce']) : '';
            if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
                throw new \RuntimeException(__('Invalid nonce.', 'wp-seo-next'), 400);
            }

            $scanId = isset($_POST['scan_id']) ? (int) $_POST['scan_id'] : 0;
            if ($scanId <= 0) {
                throw new \RuntimeException(__('Missing scan_id.', 'wp-seo-next'), 400);
            }

            return $this->jobs->cancelScan($scanId);
        });
    }



    
public function ajaxScanStep(): void
    {
        $this->ajaxSafe(function (): array {
            if (!current_user_can(Capabilities::CAP_TOOLS)) {
                throw new \RuntimeException(__('Permission denied.', 'wp-seo-next'), 403);
            }

            $nonce = isset($_POST['nonce']) ? (string) wp_unslash($_POST['nonce']) : '';
            if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
                throw new \RuntimeException(__('Invalid nonce.', 'wp-seo-next'), 400);
            }

            $scanId = isset($_POST['scan_id']) ? (int) $_POST['scan_id'] : 0;
            if ($scanId <= 0) {
                throw new \RuntimeException(__('Missing scan_id.', 'wp-seo-next'), 400);
            }

            $maxJobs = isset($_POST['max_jobs']) ? (int) $_POST['max_jobs'] : 1;
            $maxJobs = max(1, min(5, $maxJobs));

            // Process only Audit scan jobs to make progress even if WP-Cron is slow/disabled.
            // Running unrelated heavy jobs here can overload shared hosting and cause 503.
            $processed = 0;
            try {
                $processed = $this->queueRunner->runOnlyTypes([
                    AuditJobs::JOB_SCAN_DISPATCH,
                    AuditJobs::JOB_SCAN_POSTS,
                    AuditJobs::JOB_SCAN_TERMS,
                    AuditJobs::JOB_SCAN_FINALIZE,
                ], $maxJobs);
            } catch (\Throwable $e) {
                $this->logger->error('Audit ajaxScanStep worker failed: ' . $e->getMessage());
                $processed = 0;
            }

            $scan = $this->scans->get($scanId);
            if (!is_array($scan)) {
                throw new \RuntimeException(__('Scan not found.', 'wp-seo-next'), 404);
            }

            $status = (string) ($scan['status'] ?? '');
            $phase  = (string) ($scan['phase'] ?? '');
            $totalPosts = (int) ($scan['total_posts'] ?? 0);
            $totalTerms = (int) ($scan['total_terms'] ?? 0);
            $scannedPosts = (int) ($scan['scanned_posts'] ?? 0);
            $scannedTerms = (int) ($scan['scanned_terms'] ?? 0);

            $total = max(0, $totalPosts + $totalTerms);
            $doneCount = max(0, $scannedPosts + $scannedTerms);

            $pct = 0;
            if ($status === 'completed') {
                $pct = 100;
            } elseif ($total > 0) {
                $pct = (int) round(($doneCount / $total) * 100);
                if ($phase === 'finalize') {
                    $pct = min(99, max($pct, 90));
                }
                $pct = max(0, min(99, $pct));
            }

            $issuesFound = $this->issues->countAll();

            return [
                'processed_jobs' => (int) $processed,
                'scan_id' => $scanId,
                'status' => $status,
                'phase' => $phase,
                'total_posts' => $totalPosts,
                'total_terms' => $totalTerms,
                'scanned_posts' => $scannedPosts,
                'scanned_terms' => $scannedTerms,
                'pct' => $pct,
                'issues_found' => $issuesFound,
                'error_message' => (string) ($scan['error_message'] ?? ''),
                'done' => ($status === 'completed' || $status === 'failed' || $status === 'cancelled'),
            ];
        });
    }



    
public function ajaxScanStatus(): void
    {
        $this->ajaxSafe(function (): array {
            if (!current_user_can(Capabilities::CAP_TOOLS)) {
                throw new \RuntimeException(__('Permission denied.', 'wp-seo-next'), 403);
            }

            $nonce = isset($_POST['nonce']) ? (string) wp_unslash($_POST['nonce']) : '';
            if (!wp_verify_nonce($nonce, self::AJAX_NONCE_ACTION)) {
                throw new \RuntimeException(__('Invalid nonce.', 'wp-seo-next'), 400);
            }

            $scanId = isset($_POST['scan_id']) ? (int) $_POST['scan_id'] : 0;
            if ($scanId <= 0) {
                throw new \RuntimeException(__('Missing scan_id.', 'wp-seo-next'), 400);
            }

            $scan = $this->scans->get($scanId);
            if (!is_array($scan)) {
                throw new \RuntimeException(__('Scan not found.', 'wp-seo-next'), 404);
            }

            $status = (string) ($scan['status'] ?? '');
            $phase  = (string) ($scan['phase'] ?? '');
            $totalPosts = (int) ($scan['total_posts'] ?? 0);
            $totalTerms = (int) ($scan['total_terms'] ?? 0);
            $scannedPosts = (int) ($scan['scanned_posts'] ?? 0);
            $scannedTerms = (int) ($scan['scanned_terms'] ?? 0);

            $total = max(0, $totalPosts + $totalTerms);
            $doneCount = max(0, $scannedPosts + $scannedTerms);

            $pct = 0;
            if ($status === 'completed') {
                $pct = 100;
            } elseif ($total > 0) {
                $pct = (int) round(($doneCount / $total) * 100);
                if ($phase === 'finalize') {
                    $pct = min(99, max($pct, 90));
                }
                $pct = max(0, min(99, $pct));
            }

            // Optional “pump”: do 1-3 lightweight audit jobs after the response is flushed.
            // Triggered by the UI polling endpoint to avoid hammering admin-ajax.
            $pump = !empty($_POST['pump']);
            if ($pump && $status === 'running') {
                $this->scheduleShutdownPump(1);
            }

            $issuesFound = 0;
            try {
                $issuesFound = $this->issues->countAll();
            } catch (\Throwable $e) {
                $issuesFound = 0;
            }

            $nextTick = function_exists('wp_next_scheduled') ? wp_next_scheduled('wpsn_queue_tick') : false;
            $nextTickUtc = $nextTick ? (string) gmdate('c', (int) $nextTick) : '';

            return [
                'scan_id' => $scanId,
                'status' => $status,
                'phase' => $phase,
                'total_posts' => $totalPosts,
                'total_terms' => $totalTerms,
                'scanned_posts' => $scannedPosts,
                'scanned_terms' => $scannedTerms,
                'pct' => $pct,
                'issues_found' => (int) $issuesFound,
                'queue_paused' => QueuePause::isPaused() ? 1 : 0,
                'queue_next_tick_utc' => $nextTickUtc,
                'error_message' => (string) ($scan['error_message'] ?? ''),
                'done' => ($status === 'completed' || $status === 'failed' || $status === 'cancelled'),
            ];
        });
    }

    /**
     * @return array<string,mixed>
     */
    private function requestFiltersFromGet(): array
    {
        return [
            's'          => isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '',
            'issue_code' => isset($_GET['issue_code']) ? sanitize_key((string) wp_unslash($_GET['issue_code'])) : '',
            'severity'   => isset($_GET['severity']) ? (int) $_GET['severity'] : 0,
            'object_type'=> isset($_GET['object_type']) ? sanitize_key((string) wp_unslash($_GET['object_type'])) : '',
            'object_id'  => isset($_GET['object_id']) ? (int) $_GET['object_id'] : 0,
        ];
    }

    /**
     * @param array<string,mixed> $filters
     * @return array<int,array<string,mixed>>
     */
    private function collectExportRows(int $scanId, array $filters): array
    {
        $rows = [];
        $page = 1;
        $perPage = 500;

        while (true) {
            $data = $scanId > 0
                ? $this->scanIssues->listForScan($scanId, $page, $perPage, $filters)
                : $this->issues->list($page, $perPage, $filters);

            $items = isset($data['items']) && is_array($data['items']) ? $data['items'] : [];
            if (empty($items)) {
                break;
            }

            foreach ($items as $row) {
                if (!is_array($row)) {
                    continue;
                }
                $rows[] = $row;
            }

            $page++;
            if ($page > 20000) {
                break;
            }
        }

        return $rows;
    }

    private function buildExportUrl(string $format, int $scanId, array $filters = []): string
    {
        $action = $format === 'json' ? 'wpsn_audit_export_json' : 'wpsn_audit_export_csv';
        $nonce = $format === 'json' ? 'wpsn_audit_export_json' : 'wpsn_audit_export_csv';

        $args = [
            'action' => $action,
            'scan_id' => (int) $scanId,
            's' => (string) ($filters['s'] ?? ''),
            'issue_code' => (string) ($filters['issue_code'] ?? ''),
            'severity' => (int) ($filters['severity'] ?? 0),
            'object_type' => (string) ($filters['object_type'] ?? ''),
            'object_id' => (int) ($filters['object_id'] ?? 0),
        ];

        return wp_nonce_url(add_query_arg($args, admin_url('admin-post.php')), $nonce);
    }

    public function handleExportCsv(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_audit_export_csv');

        $scanId = isset($_GET['scan_id']) ? max(0, (int) $_GET['scan_id']) : 0;
        $filters = $this->requestFiltersFromGet();

        $filename = $scanId > 0
            ? 'wpsn-audit-scan-' . $scanId . '-' . gmdate('Ymd-His') . '.csv'
            : 'wpsn-audit-latest-' . gmdate('Ymd-His') . '.csv';

        nocache_headers();
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename=' . $filename);

        $out = fopen('php://output', 'w');
        if (!is_resource($out)) {
            exit;
        }

        fputcsv($out, [
            'scan_id',
            'severity',
            'severity_label',
            'issue_code',
            'message',
            'suggestion',
            'object_type',
            'object_subtype',
            'object_id',
            'object_title',
            'object_url',
            'detected_at',
            'details',
        ]);

        foreach ($this->collectExportRows($scanId, $filters) as $row) {
            if (!is_array($row)) {
                continue;
            }
            fputcsv($out, [
                $scanId,
                (int) ($row['severity'] ?? 0),
                $this->severityLabel((int) ($row['severity'] ?? 0)),
                (string) ($row['issue_code'] ?? ''),
                (string) ($row['message'] ?? ''),
                (string) ($row['suggestion'] ?? ''),
                (string) ($row['object_type'] ?? ''),
                (string) ($row['object_subtype'] ?? ''),
                (int) ($row['object_id'] ?? 0),
                (string) ($row['object_title'] ?? ''),
                (string) ($row['object_url'] ?? ''),
                (string) ($row['detected_at'] ?? ''),
                (string) ($row['details'] ?? ''),
            ]);
        }

        fclose($out);
        exit;
    }

    public function handleExportJson(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_audit_export_json');

        $scanId = isset($_GET['scan_id']) ? max(0, (int) $_GET['scan_id']) : 0;
        $filters = $this->requestFiltersFromGet();
        $rows = $this->collectExportRows($scanId, $filters);
        $lastScan = get_option('wpsn_audit_last_scan', []);
        if (!is_array($lastScan)) {
            $lastScan = [];
        }

        $payload = [
            'exported_at' => gmdate('c'),
            'view' => $scanId > 0 ? 'scan_snapshot' : 'live_latest',
            'scan_id' => $scanId,
            'filters' => $filters,
            'rows_total' => count($rows),
            'scan' => $scanId > 0 ? ($this->scans->get($scanId) ?? []) : [],
            'last_scan' => $lastScan,
            'items' => $rows,
        ];

        $filename = $scanId > 0
            ? 'wpsn-audit-scan-' . $scanId . '-' . gmdate('Ymd-His') . '.json'
            : 'wpsn-audit-latest-' . gmdate('Ymd-His') . '.json';

        nocache_headers();
        header('Content-Type: application/json; charset=utf-8');
        header('Content-Disposition: attachment; filename=' . $filename);

        echo wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        exit;
    }

    public function handleRestoreScan(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        $scanId = isset($_GET['scan_id']) ? max(0, (int) $_GET['scan_id']) : 0;
        if ($scanId <= 0) {
            wp_safe_redirect(admin_url('tools.php?page=wpsn-audit'));
            exit;
        }

        check_admin_referer('wpsn_audit_restore_scan_' . $scanId, 'wpsn_nonce');

        $scan = $this->scans->get($scanId);
        if (!is_array($scan) || empty($scan)) {
            wp_die(esc_html__('Scan snapshot not found.', 'wp-seo-next'));
        }

        if (!$this->issues->restoreFromScan($scanId)) {
            wp_die(esc_html__('Could not restore the saved scan snapshot.', 'wp-seo-next'));
        }

        $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 = [];
        }
        $summary['live_issues_total'] = $issuesTotal;
        $summary['live_issues_by_code'] = $counts;
        $summary['live_refreshed_at'] = time();
        $summary['live_source'] = 'restored_scan';
        $summary['live_source_scan_id'] = $scanId;
        update_option('wpsn_audit_last_scan', $summary, false);

        wp_safe_redirect(admin_url('tools.php?page=wpsn-audit&wpsn_notice=audit_scan_restored'));
        exit;
    }


    public function handleBulkAction(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_audit_bulk_action', 'wpsn_nonce');

        $scanId = isset($_POST['scan_id']) ? max(0, (int) $_POST['scan_id']) : 0;
        $bulkAction = isset($_POST['bulk_action']) ? sanitize_key((string) wp_unslash($_POST['bulk_action'])) : '';
        $limit = isset($_POST['limit']) ? (int) $_POST['limit'] : 500;
        $liveProfile = isset($_POST['live_profile']) ? QueueLiveRunnerProfiles::normalize((string) wp_unslash($_POST['live_profile'])) : QueueLiveRunnerProfiles::DEFAULT_PROFILE;
        $onlyIfEmpty = !empty($_POST['only_if_empty']);
        $overwrite = !empty($_POST['overwrite_suggestions']);

        $filters = [
            's'          => isset($_POST['s']) ? sanitize_text_field((string) wp_unslash($_POST['s'])) : '',
            'issue_code' => isset($_POST['issue_code']) ? sanitize_key((string) wp_unslash($_POST['issue_code'])) : '',
            'severity'   => isset($_POST['severity']) ? (int) $_POST['severity'] : 0,
            'object_type'=> isset($_POST['object_type']) ? sanitize_key((string) wp_unslash($_POST['object_type'])) : '',
            'object_id'  => isset($_POST['object_id']) ? (int) $_POST['object_id'] : 0,
        ];

        $pageUrl = admin_url('tools.php?page=wpsn-audit');
        if ($scanId > 0) {
            $pageUrl = add_query_arg(['scan_id' => $scanId], $pageUrl);
        }

        // Preserve current filters.
        $pageUrl = add_query_arg([
            's' => (string) $filters['s'],
            'issue_code' => (string) $filters['issue_code'],
            'severity' => (int) $filters['severity'],
            'object_type' => (string) $filters['object_type'],
            'object_id' => (int) $filters['object_id'],
        ], $pageUrl);

        $res = $this->jobs->scheduleBulkAction($scanId, $filters, $bulkAction, [
            'limit' => $limit,
            'preferred_profile' => $liveProfile,
            'only_if_empty' => $onlyIfEmpty,
            'overwrite_suggestions' => $overwrite,
            'actor_user_id' => (int) get_current_user_id(),
            'return_url' => $pageUrl,
        ]);

        $pageUrl = add_query_arg([
            'wpsn_notice' => 'audit_bulk_queued',
            'bulk_action' => (string) ($res['action'] ?? ''),
            'bulk_jobs' => (int) ($res['queued_jobs'] ?? 0),
            'bulk_targets' => (int) ($res['targets'] ?? 0),
            'bulk_eligible' => (int) ($res['eligible_targets'] ?? 0),
            'bulk_skipped' => (int) ($res['skipped'] ?? 0),
            'bulk_session' => (string) ($res['session_id'] ?? ''),
            'bulk_limit' => $limit,
        ], $pageUrl);

        wp_safe_redirect($pageUrl);
        exit;
    }


    
    public function handleBulkSelectedAction(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_audit_bulk_selected_action', 'wpsn_nonce');

        $scanId = isset($_POST['scan_id']) ? max(0, (int) $_POST['scan_id']) : 0;
        $bulkAction = isset($_POST['bulk_action']) ? sanitize_key((string) wp_unslash($_POST['bulk_action'])) : '';
        $liveProfile = isset($_POST['live_profile']) ? QueueLiveRunnerProfiles::normalize((string) wp_unslash($_POST['live_profile'])) : QueueLiveRunnerProfiles::DEFAULT_PROFILE;
        $onlyIfEmpty = !empty($_POST['only_if_empty']);
        $overwrite = !empty($_POST['overwrite_suggestions']);

        $rawSelected = isset($_POST['selected']) && is_array($_POST['selected']) ? (array) $_POST['selected'] : [];
        $targets = [];

        foreach ($rawSelected as $val) {
            $val = (string) wp_unslash($val);
            $parts = explode('|', $val);
            if (count($parts) < 4) {
                continue;
            }

            $objType = sanitize_key((string) ($parts[0] ?? ''));
            $objId = (int) ($parts[1] ?? 0);
            $subType = sanitize_key((string) ($parts[2] ?? ''));
            $issueCode = sanitize_key((string) ($parts[3] ?? ''));

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

            $targets[] = [
                'object_type' => $objType,
                'object_id' => $objId,
                'object_subtype' => $subType,
                'issue_code' => $issueCode,
            ];
        }

        // Preserve current filters from POST (same keys as GET filters).
        $filters = [
            's' => isset($_POST['s']) ? sanitize_text_field((string) wp_unslash($_POST['s'])) : '',
            'issue_code' => isset($_POST['issue_code']) ? sanitize_key((string) wp_unslash($_POST['issue_code'])) : '',
            'severity' => isset($_POST['severity']) ? (int) $_POST['severity'] : 0,
            'object_type' => isset($_POST['object_type']) ? sanitize_key((string) wp_unslash($_POST['object_type'])) : '',
            'object_id' => isset($_POST['object_id']) ? (int) $_POST['object_id'] : 0,
        ];

        $pageUrl = admin_url('tools.php?page=wpsn-audit');
        if ($scanId > 0) {
            $pageUrl = add_query_arg(['scan_id' => $scanId], $pageUrl);
        }

        $pageUrl = add_query_arg([
            's' => (string) $filters['s'],
            'issue_code' => (string) $filters['issue_code'],
            'severity' => (int) $filters['severity'],
            'object_type' => (string) $filters['object_type'],
            'object_id' => (int) $filters['object_id'],
        ], $pageUrl);

        $res = $this->jobs->scheduleBulkActionForTargets($targets, $bulkAction, [
            'preferred_profile' => $liveProfile,
            'only_if_empty' => $onlyIfEmpty,
            'overwrite_suggestions' => $overwrite,
            'actor_user_id' => (int) get_current_user_id(),
            'return_url' => $pageUrl,
        ]);

        $pageUrl = add_query_arg([
            'wpsn_notice' => 'audit_bulk_queued',
            'bulk_action' => (string) ($res['action'] ?? ''),
            'bulk_jobs' => (int) ($res['queued_jobs'] ?? 0),
            'bulk_targets' => (int) ($res['targets'] ?? 0),
            'bulk_eligible' => (int) ($res['eligible_targets'] ?? 0),
            'bulk_skipped' => (int) ($res['skipped'] ?? 0),
            'bulk_session' => (string) ($res['session_id'] ?? ''),
        ], $pageUrl);

        wp_safe_redirect($pageUrl);
        exit;
    }


public function handleQuickAction(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_audit_quick_action', 'wpsn_nonce');

        $scanId = isset($_POST['scan_id']) ? max(0, (int) $_POST['scan_id']) : 0;
        $bulkAction = isset($_POST['bulk_action']) ? sanitize_key((string) wp_unslash($_POST['bulk_action'])) : '';
        $objType = isset($_POST['object_type']) ? sanitize_key((string) wp_unslash($_POST['object_type'])) : '';
        $objId = isset($_POST['object_id']) ? (int) $_POST['object_id'] : 0;
        $subType = isset($_POST['object_subtype']) ? sanitize_key((string) wp_unslash($_POST['object_subtype'])) : '';
        $issueCode = isset($_POST['issue_code']) ? sanitize_key((string) wp_unslash($_POST['issue_code'])) : '';

        $returnUrl = isset($_POST['return_url']) ? esc_url_raw((string) wp_unslash($_POST['return_url'])) : '';
        if ($returnUrl === '') {
            $returnUrl = admin_url('tools.php?page=wpsn-audit');
            if ($scanId > 0) {
                $returnUrl = add_query_arg(['scan_id' => $scanId], $returnUrl);
            }
        }

        if ($objType === '' || $objId <= 0 || $bulkAction === '') {
            wp_safe_redirect($returnUrl);
            exit;
        }

        // Run as a targeted bulk action for a single object.
        $filters = [
            'object_type' => $objType,
            'object_id' => $objId,
            // Keep issue_code so scheduleBulkAction picks relevant fields (title vs description).
            'issue_code' => $issueCode,
        ];

        $res = $this->jobs->scheduleBulkAction($scanId, $filters, $bulkAction, [
            'limit' => 1,
            'only_if_empty' => isset($_POST['only_if_empty']) ? (bool) $_POST['only_if_empty'] : (strpos($issueCode, 'missing_') === 0),
            'overwrite_suggestions' => !empty($_POST['overwrite_suggestions']),
            'actor_user_id' => (int) get_current_user_id(),
            'return_url' => $returnUrl,
        ]);

        // Preserve user's context.
        $returnUrl = add_query_arg([
            'wpsn_notice' => 'audit_bulk_queued',
            'bulk_action' => (string) ($res['action'] ?? ''),
            'bulk_jobs' => (int) ($res['queued_jobs'] ?? 0),
            'bulk_targets' => (int) ($res['targets'] ?? 0),
            'bulk_eligible' => (int) ($res['eligible_targets'] ?? 0),
            'bulk_skipped' => (int) ($res['skipped'] ?? 0),
            'bulk_session' => (string) ($res['session_id'] ?? ''),
        ], $returnUrl);

        wp_safe_redirect($returnUrl);
        exit;
    }

    public function renderPage(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to access this page.', 'wp-seo-next'));
        }

        $scanId = isset($_GET['scan_id']) ? max(0, (int) $_GET['scan_id']) : 0;

        $filters = [
            's'          => isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '',
            'issue_code' => isset($_GET['issue_code']) ? sanitize_key((string) wp_unslash($_GET['issue_code'])) : '',
            'severity'   => isset($_GET['severity']) ? (int) $_GET['severity'] : 0,
            'object_type'=> isset($_GET['object_type']) ? sanitize_key((string) wp_unslash($_GET['object_type'])) : '',
            'object_id'  => isset($_GET['object_id']) ? (int) $_GET['object_id'] : 0,
        ];

        $paged   = isset($_GET['paged']) ? max(1, (int) $_GET['paged']) : 1;
        $perPage = 20;

        if ($scanId > 0) {
            $data = $this->scanIssues->listForScan($scanId, $paged, $perPage, $filters);
        } else {
            $data = $this->issues->list($paged, $perPage, $filters);
        }
        $items = $data['items'];
        $total = (int) $data['total'];
        $totalPages = (int) ceil($total / $perPage);

        $counts = $scanId > 0 ? $this->scanIssues->countsByCode($scanId) : $this->issues->countsByCode();
        $lastScan = get_option('wpsn_audit_last_scan');
        if (!is_array($lastScan)) {
            $lastScan = [];
        }
        $liveIssuesTotal = array_key_exists('live_issues_total', $lastScan) ? (int) $lastScan['live_issues_total'] : null;
        $liveRefreshedAt = isset($lastScan['live_refreshed_at']) ? (int) $lastScan['live_refreshed_at'] : 0;
        $liveSource = isset($lastScan['live_source']) ? sanitize_key((string) $lastScan['live_source']) : '';
        $liveSourceScanId = isset($lastScan['live_source_scan_id']) ? (int) $lastScan['live_source_scan_id'] : 0;

        // Scan list for history + selector.
        $this->scans->repairFrozenSummaries(20);
        $scanList = $this->scans->list(1, 20);
        $scanItems = $scanList['items'];
        $activeScan = $scanId > 0 ? $this->scans->get($scanId) : null;

        $actionUrl = admin_url('admin-post.php');
        $pageUrl   = admin_url('tools.php?page=wpsn-audit');

        $issueOptions = [
            '' => __('All issues', 'wp-seo-next'),
            AuditRunner::ISSUE_MISSING_TITLE => __('Missing SEO title', 'wp-seo-next'),
            AuditRunner::ISSUE_MISSING_DESCRIPTION => __('Missing meta description', 'wp-seo-next'),
            AuditRunner::ISSUE_DUPLICATE_TITLE => __('Duplicate SEO title', 'wp-seo-next'),
            AuditRunner::ISSUE_DUPLICATE_DESCRIPTION => __('Duplicate meta description', 'wp-seo-next'),
            AuditRunner::ISSUE_TITLE_TOO_SHORT => __('SEO title too short', 'wp-seo-next'),
            AuditRunner::ISSUE_TITLE_TOO_LONG => __('SEO title too long', 'wp-seo-next'),
            AuditRunner::ISSUE_DESCRIPTION_TOO_SHORT => __('Meta description too short', 'wp-seo-next'),
            AuditRunner::ISSUE_DESCRIPTION_TOO_LONG => __('Meta description too long', 'wp-seo-next'),
            AuditRunner::ISSUE_TITLE_TEMPLATE_BROKEN => __('SEO title has unresolved tokens', 'wp-seo-next'),
            AuditRunner::ISSUE_DESCRIPTION_TEMPLATE_BROKEN => __('Meta description has unresolved tokens', 'wp-seo-next'),
            AuditRunner::ISSUE_NOINDEX => __('Noindex', 'wp-seo-next'),
            AuditRunner::ISSUE_CANONICAL_INVALID => __('Invalid canonical', 'wp-seo-next'),
            AuditRunner::ISSUE_CANONICAL_EXTERNAL => __('External canonical', 'wp-seo-next'),
            AuditRunner::ISSUE_CANONICAL_MISMATCH => __('Canonical mismatch', 'wp-seo-next'),
            AuditRunner::ISSUE_SOCIAL_MISSING_IMAGE => __('Missing OG/Twitter image', 'wp-seo-next'),
            AuditRunner::ISSUE_NEWS_SITEMAP_EMPTY => __('News sitemap empty', 'wp-seo-next'),
            AuditRunner::ISSUE_NEWS_SITEMAP_MISSING_POST_TITLE => __('News sitemap: missing post title', 'wp-seo-next'),
            AuditRunner::ISSUE_VIDEO_SITEMAP_EMPTY => __('Video sitemap empty', 'wp-seo-next'),
            AuditRunner::ISSUE_VIDEO_SITEMAP_INVALID_URL => __('Video sitemap: invalid URL', 'wp-seo-next'),
            AuditRunner::ISSUE_VIDEO_SITEMAP_MISSING_THUMBNAIL => __('Video sitemap: missing thumbnail', 'wp-seo-next'),
            AuditRunner::ISSUE_VIDEO_SITEMAP_MISSING_TITLE => __('Video sitemap: missing title', 'wp-seo-next'),
            AuditRunner::ISSUE_VIDEO_SITEMAP_MISSING_DESCRIPTION => __('Video sitemap: missing description', 'wp-seo-next'),
            AuditRunner::ISSUE_VIDEO_SITEMAP_INVALID_DURATION => __('Video sitemap: invalid duration', 'wp-seo-next'),
        ];

        $sevOptions = [
            0 => __('All severities', 'wp-seo-next'),
            AuditRunner::SEVERITY_HIGH => __('High', 'wp-seo-next'),
            AuditRunner::SEVERITY_MEDIUM => __('Medium', 'wp-seo-next'),
            AuditRunner::SEVERITY_LOW => __('Low', 'wp-seo-next'),
        ];

        $typeOptions = [
            '' => __('All types', 'wp-seo-next'),
            'post' => __('Posts', 'wp-seo-next'),
            'term' => __('Terms', 'wp-seo-next'),
            'system' => __('System', 'wp-seo-next'),
        ];

        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('SEO Audit', 'wp-seo-next'); ?></h1>

            <p class="description">
                <?php echo esc_html__('Rule-based audit that helps you spot common SEO issues (missing titles/descriptions, duplicate titles, noindex pages, canonical problems).', 'wp-seo-next'); ?>
            </p>

            <div style="margin: 12px 0; padding: 12px; background: #fff; border: 1px solid #ccd0d4;">
                <strong>
                    <?php
                        if ($scanId > 0) {
                            echo esc_html(sprintf(__('Viewing scan #%d:', 'wp-seo-next'), $scanId));
                        } else {
                            echo esc_html__('Current live issues:', 'wp-seo-next');
                        }
                    ?>
                </strong>
                <?php
                if ($scanId > 0 && is_array($activeScan)) {
                    $finished = (string) ($activeScan['finished_at'] ?? '');
                    $status   = (string) ($activeScan['status'] ?? '');
                    $issuesT  = (int) ($activeScan['issues_total'] ?? 0);
                    $dur      = (int) ($activeScan['duration_sec'] ?? 0);

                    if ($finished !== '') {
                        $ts = strtotime($finished);
                        if ($ts) {
                            echo esc_html(date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $ts));
                        } else {
                            echo esc_html($finished);
                        }
                    } else {
                        echo esc_html__('In progress / not finished', 'wp-seo-next');
                    }

                    if ($status !== '') {
                        echo ' — ' . esc_html(ucfirst($status));
                    }
                    echo ' — ' . esc_html(sprintf(__('Issues: %d', 'wp-seo-next'), $issuesT));
                    if ($dur > 0) {
                        echo ' — ' . esc_html(sprintf(__('Duration: %ds', 'wp-seo-next'), $dur));
                    }
                } else {
                    $currentIssuesTotal = $liveIssuesTotal !== null ? $liveIssuesTotal : (int) ($lastScan['issues_total'] ?? 0);
                    if (!empty($lastScan['finished_at'])) {
                        echo esc_html(sprintf(
                            __('Last completed scan: %1$s', 'wp-seo-next'),
                            date_i18n(get_option('date_format') . ' ' . get_option('time_format'), (int) $lastScan['finished_at'])
                        ));
                        echo ' — ' . esc_html(sprintf(__('Current issues: %d', 'wp-seo-next'), $currentIssuesTotal));
                        if ($liveSource === 'restored_scan' && $liveSourceScanId > 0) {
                            echo ' — ' . esc_html(sprintf(__('restored from scan #%d', 'wp-seo-next'), $liveSourceScanId));
                        } elseif ($liveRefreshedAt > 0) {
                            echo ' — ' . esc_html(sprintf(
                                __('live refresh: %s', 'wp-seo-next'),
                                date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $liveRefreshedAt)
                            ));
                        }
                    } else {
                        if ($liveSource === 'restored_scan' && $liveSourceScanId > 0) {
                            echo esc_html(sprintf(__('Restored from saved scan #%d', 'wp-seo-next'), $liveSourceScanId));
                            echo ' — ' . esc_html(sprintf(__('Current issues: %d', 'wp-seo-next'), $currentIssuesTotal));
                        } else {
                            echo esc_html__('Never', 'wp-seo-next');
                        }
                    }
                }
                ?>

                
                <form id="wpsn-audit-scan-form" method="post" action="<?php echo esc_url($actionUrl); ?>" style="margin-top:10px; display:flex; gap:12px; align-items:center; flex-wrap:wrap;">
                    <input type="hidden" name="action" value="wpsn_audit_scan" />
                    <?php wp_nonce_field('wpsn_audit_scan', 'wpsn_nonce'); ?>

                    <label>
                        <input type="checkbox" id="wpsn-audit-include-terms" name="include_terms" value="1" />
                        <?php echo esc_html__('Include terms/taxonomies', 'wp-seo-next'); ?>
                    </label>

                    <label>
                        <input type="checkbox" id="wpsn-audit-check-social" name="check_social" value="1" />
                        <?php echo esc_html__('Check social image (OG/Twitter)', 'wp-seo-next'); ?>
                    </label>

                    <label>
                        <input type="checkbox" id="wpsn-audit-ultra-fast" name="ultra_fast" value="1" checked="checked" />
                        <?php echo esc_html__('Ultra-fast minimal for Quick SQL only', 'wp-seo-next'); ?>
                    </label>

                    <button type="submit" class="button button-primary" id="wpsn-audit-scan-submit" name="scan_engine" value="async">
                        <?php echo esc_html__('Run accurate scan', 'wp-seo-next'); ?>
                    </button>

                    <button type="submit" class="button" id="wpsn-audit-scan-submit-sql" name="scan_engine" value="sql">
                        <?php echo esc_html__('Run quick SQL scan', 'wp-seo-next'); ?>
                    </button>

                    <span class="spinner" id="wpsn-audit-scan-spinner" style="float:none; margin:0;"></span>
                </form>

                <div id="wpsn-audit-scan-progress" style="display:none; margin-top:12px; padding:12px; border:1px solid #dcdcde; background:#fff;">
                    <div id="wpsn-audit-scan-progress-meta" style="font-weight:600; margin-bottom:6px;"></div>
                    <div style="height:8px; background:#f0f0f1; border-radius:999px; overflow:hidden;">
                        <div id="wpsn-audit-scan-progress-bar" style="width:0%; height:100%; background:#2271b1;"></div>
                    </div>
                    <p id="wpsn-audit-scan-progress-text" class="description" style="margin:8px 0 0;"></p>
                    <p id="wpsn-audit-scan-progress-error" class="description" style="display:none; margin:8px 0 0; color:#b32d2e;"></p>
                    <p style="margin:10px 0 0; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
                        <a href="#" class="button button-secondary" id="wpsn-audit-scan-cancel" style="display:none;"><?php echo esc_html__('Cancel scan', 'wp-seo-next'); ?></a>
                        <a href="#" class="button" id="wpsn-audit-scan-refresh" style="display:none;"><?php echo esc_html__('Refresh', 'wp-seo-next'); ?></a>
                    </p>
                </div>

                <p class="description" style="margin-top:8px;">
                    <?php echo esc_html__('Accurate scan uses the existing async audit pipeline and catches full render-time template cases. Quick SQL scan stays available as a fast fallback and now also catches obvious broken explicit overrides plus term duplicate meta, but it remains heuristic and can still miss some render-time template cases.', 'wp-seo-next'); ?>
                </p>

<form method="post" action="<?php echo esc_url($actionUrl); ?>" style="margin-top:10px;">
                    <input type="hidden" name="action" value="wpsn_audit_clear" />
                    <?php wp_nonce_field('wpsn_audit_clear', 'wpsn_nonce'); ?>
                    <?php submit_button(__('Clear issues', 'wp-seo-next'), 'secondary', 'submit', false); ?>
                </form>

                <form method="post" action="<?php echo esc_url($actionUrl); ?>" style="margin-top:10px;">
                    <input type="hidden" name="action" value="wpsn_audit_clear_history" />
                    <?php wp_nonce_field('wpsn_audit_clear_history', 'wpsn_nonce'); ?>
                    <?php submit_button(__('Clear scan history', 'wp-seo-next'), 'delete', 'submit', false); ?>
                    <span class="description" style="margin-left:8px;">
                        <?php echo esc_html__('Deletes stored scan runs and per-scan issues. Does not affect post/term SEO data.', 'wp-seo-next'); ?>
                    </span>
                </form>
            </div>

            <h2><?php echo esc_html__('Scan history', 'wp-seo-next'); ?></h2>
            <table class="widefat striped" style="margin-top: 10px;">
                <thead>
                    <tr>
                        <th style="width:90px;">#</th>
                        <th style="width:140px;"><?php echo esc_html__('Status', 'wp-seo-next'); ?></th>
                        <th style="width:220px;"><?php echo esc_html__('Finished', 'wp-seo-next'); ?></th>
                        <th style="width:120px;"><?php echo esc_html__('Issues', 'wp-seo-next'); ?></th>
                        <th style="width:190px;"><?php echo esc_html__('Δ vs previous', 'wp-seo-next'); ?></th>
                        <th style="width:140px;"><?php echo esc_html__('Scanned', 'wp-seo-next'); ?></th>
                        <th style="width:120px;"><?php echo esc_html__('Duration', 'wp-seo-next'); ?></th>
                        <th style="width:280px;"><?php echo esc_html__('Actions', 'wp-seo-next'); ?></th>
                    </tr>
                </thead>
                <tbody>
                    <?php if (empty($scanItems)) : ?>
                        <tr><td colspan="8"><?php echo esc_html__('No scans yet.', 'wp-seo-next'); ?></td></tr>
                    <?php else : ?>
                        <?php foreach ($scanItems as $scanIndex => $srow) :
                            $sid = (int) ($srow['id'] ?? 0);
                            $status = (string) ($srow['status'] ?? '');
                            $finished = (string) ($srow['finished_at'] ?? '');
                            $issuesT = (int) ($srow['issues_total'] ?? 0);
                            $sp = (int) ($srow['scanned_posts'] ?? 0);
                            $st = (int) ($srow['scanned_terms'] ?? 0);
                            $dur = (int) ($srow['duration_sec'] ?? 0);
                            $prevScanRow = isset($scanItems[$scanIndex + 1]) && is_array($scanItems[$scanIndex + 1]) ? $scanItems[$scanIndex + 1] : null;
                            $scanDeltaLabel = __('—', 'wp-seo-next');
                            $scanDeltaDetails = '';
                            $scanDeltaStyle = '';
                            if (is_array($prevScanRow)) {
                                $prevIssuesTotal = isset($prevScanRow['issues_total']) ? (int) $prevScanRow['issues_total'] : 0;
                                $scanDelta = $issuesT - $prevIssuesTotal;
                                $scanDeltaLabel = $this->formatSignedNumber($scanDelta);
                                if ($scanDelta < 0) {
                                    $scanDeltaStyle = 'color:#008a20;font-weight:600;';
                                } elseif ($scanDelta > 0) {
                                    $scanDeltaStyle = 'color:#b32d2e;font-weight:600;';
                                }
                                $scanDeltaData = $this->issueDeltaData(
                                    $this->normalizeIssueCounts($prevScanRow['issues_by_code'] ?? []),
                                    $this->normalizeIssueCounts($srow['issues_by_code'] ?? [])
                                );
                                $scanDeltaDetails = $this->formatIssueDeltaHighlights((array) ($scanDeltaData['changes'] ?? []));
                            }

                            $finishedLabel = $finished !== '' ? $finished : __('—', 'wp-seo-next');
                            $ts = $finished !== '' ? strtotime($finished) : false;
                            if ($ts) {
                                $finishedLabel = date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $ts);
                            }

                            $viewUrl = add_query_arg(['scan_id' => $sid], $pageUrl);
                            $restoreUrl = wp_nonce_url(
                                add_query_arg([
                                    'action' => 'wpsn_audit_restore_scan',
                                    'scan_id' => $sid,
                                ], $actionUrl),
                                'wpsn_audit_restore_scan_' . $sid,
                                'wpsn_nonce'
                            );
                            $jsonUrl = $this->buildExportUrl('json', $sid, []);
                            $isActive = ($scanId > 0 && $sid === $scanId);
                            ?>
                            <tr <?php echo $isActive ? 'style="background:#f0f6fc;"' : ''; ?>>
                                <td><strong><?php echo (int) $sid; ?></strong></td>
                                <td><?php echo esc_html($status !== '' ? ucfirst($status) : ''); ?></td>
                                <td><?php echo esc_html($finishedLabel); ?></td>
                                <td><?php echo esc_html((string) $issuesT); ?></td>
                                <td>
                                    <span<?php echo $scanDeltaStyle !== '' ? ' style="' . esc_attr($scanDeltaStyle) . '"' : ''; ?>><?php echo esc_html($scanDeltaLabel); ?></span>
                                    <?php if ($scanDeltaDetails !== '') : ?>
                                        <br/><span class="description"><?php echo esc_html($scanDeltaDetails); ?></span>
                                    <?php endif; ?>
                                </td>
                                <td><?php echo esc_html(sprintf(__('Posts: %d, Terms: %d', 'wp-seo-next'), $sp, $st)); ?></td>
                                <td><?php echo esc_html($dur > 0 ? $dur . 's' : ''); ?></td>
                                <td>
                                    <a class="button button-small" href="<?php echo esc_url($viewUrl); ?>"><?php echo esc_html__('View issues', 'wp-seo-next'); ?></a>
                                    <a class="button button-small" href="<?php echo esc_url($restoreUrl); ?>" style="margin-left:4px;"><?php echo esc_html__('Restore live', 'wp-seo-next'); ?></a>
                                    <a class="button button-small" href="<?php echo esc_url($jsonUrl); ?>" style="margin-left:4px;"><?php echo esc_html__('JSON', 'wp-seo-next'); ?></a>
                                    <?php if ($status === 'running') :
                                        $cancelUrl = wp_nonce_url(add_query_arg(['action' => 'wpsn_audit_cancel', 'scan_id' => $sid], $actionUrl), 'wpsn_audit_cancel');
                                        ?>
                                        <a class="button button-small" href="<?php echo esc_url($cancelUrl); ?>" style="margin-left:4px;"><?php echo esc_html__('Cancel', 'wp-seo-next'); ?></a>
                                    <?php endif; ?>
                                </td>
                            </tr>
                        <?php endforeach; ?>
                    <?php endif; ?>
                </tbody>
            </table>
            <p class="description" style="margin-top:8px; max-width:980px;">
                <?php echo esc_html__('History rows are frozen snapshots. Bulk fixes refresh only the current live issues list; they no longer rewrite older scan totals. Use Restore live to reopen an older snapshot as the current view, or JSON to export that snapshot for debugging.', 'wp-seo-next'); ?>
            </p>

            <?php
            // Automation settings (stored in main plugin settings).
            $repo = new SettingsRepository();
            $allSettings = $repo->getAll();
            $auto = [];
            if (isset($allSettings['audit']['automation']) && is_array($allSettings['audit']['automation'])) {
                $auto = $allSettings['audit']['automation'];
            }

            $scheduleMode = isset($auto['schedule']) ? sanitize_key((string) $auto['schedule']) : 'off';
            if (!in_array($scheduleMode, ['off', 'daily', 'weekly'], true)) {
                $scheduleMode = 'off';
            }
            $hourUtc = isset($auto['hour_utc']) ? (int) $auto['hour_utc'] : 4;
            $hourUtc = max(0, min(23, $hourUtc));
            $weekdayUtc = isset($auto['weekday_utc']) ? (int) $auto['weekday_utc'] : 1;
            $weekdayUtc = max(0, min(6, $weekdayUtc));

            $autoIncludeTerms = !isset($auto['include_terms']) || !empty($auto['include_terms']);
            $autoCheckSocial = !isset($auto['check_social_image']) || !empty($auto['check_social_image']);

            $autoScanMode = isset($auto['scan_mode']) ? sanitize_key((string) $auto['scan_mode']) : 'normal';
            if (!in_array($autoScanMode, ['normal', 'light', 'fast'], true)) {
                $autoScanMode = 'normal';
            }

            $autoSmartWindow = !isset($auto['smart_window']) || !empty($auto['smart_window']);
            $autoMaxPending = isset($auto['smart_window_max_pending']) ? (int) $auto['smart_window_max_pending'] : 50;
            $autoMaxPending = max(0, min(5000, $autoMaxPending));

            $keepScans = isset($allSettings['audit']['history_keep_scans']) ? (int) $allSettings['audit']['history_keep_scans'] : 20;
            $keepScans = max(0, min(500, $keepScans));

            $queueMode = isset($allSettings['performance']['queue']['mode']) ? (string) $allSettings['performance']['queue']['mode'] : 'auto';
            $queuePaused = QueuePause::isPaused();
            $pauseUntil = QueuePause::pauseUntil();
            $pauseReason = QueuePause::reason();
            $nextTick = wp_next_scheduled(AuditAutomation::CRON_TICK);
            ?>

            <div class="wpsn-card" style="margin-top:12px; max-width: 980px;">
                <h2 style="margin-top:0;"><?php echo esc_html__('Automation', 'wp-seo-next'); ?></h2>
                <p class="description">
                    <?php echo esc_html__('Optional scheduled Audit scans (daily/weekly). Uses WP-Cron (hourly tick). Scans are started at the configured hour (UTC).', 'wp-seo-next'); ?>
                </p>

                <?php if ($queueMode === 'manual') : ?>
                    <div class="notice notice-warning inline"><p>
                        <?php echo esc_html__('Queue mode is set to Manual. Scheduled audit scans are paused to avoid accumulating jobs.', 'wp-seo-next'); ?>
                    </p></div>
                <?php endif; ?>

                <?php if ($queuePaused) : ?>
                    <div class="notice notice-warning inline"><p>
                        <?php
                        $untilLabel = $pauseUntil > 0 ? date_i18n(get_option('date_format') . ' ' . get_option('time_format'), $pauseUntil) : '';
                        $msg = $untilLabel !== '' ? sprintf(__('Queue is paused until %s (UTC/local time depending on WP settings).', 'wp-seo-next'), $untilLabel) : __('Queue is paused.', 'wp-seo-next');
                        if ($pauseReason !== '') {
                            $msg .= ' ' . sprintf(__('Reason: %s', 'wp-seo-next'), $pauseReason);
                        }
                        echo esc_html($msg);
                        ?>
                    </p></div>
                <?php endif; ?>

                <form method="post" action="<?php echo esc_url($actionUrl); ?>">
                    <input type="hidden" name="action" value="wpsn_audit_save_automation" />
                    <?php wp_nonce_field('wpsn_audit_save_automation', 'wpsn_nonce'); ?>

                    <table class="widefat striped" style="margin-top:10px;">
                        <tbody>
                            <tr>
                                <th style="width:260px;"><?php echo esc_html__('Schedule', 'wp-seo-next'); ?></th>
                                <td>
                                    <select name="schedule">
                                        <option value="off" <?php selected($scheduleMode, 'off'); ?>><?php echo esc_html__('Off', 'wp-seo-next'); ?></option>
                                        <option value="daily" <?php selected($scheduleMode, 'daily'); ?>><?php echo esc_html__('Daily', 'wp-seo-next'); ?></option>
                                        <option value="weekly" <?php selected($scheduleMode, 'weekly'); ?>><?php echo esc_html__('Weekly', 'wp-seo-next'); ?></option>
                                    </select>
                                    <span class="description" style="margin-left:8px;">
                                        <?php echo esc_html__('Runs are started at the hour below (UTC).', 'wp-seo-next'); ?>
                                    </span>
                                </td>
                            </tr>
                            <tr>
                                <th><?php echo esc_html__('Hour (UTC)', 'wp-seo-next'); ?></th>
                                <td><input type="number" name="hour_utc" value="<?php echo (int) $hourUtc; ?>" min="0" max="23" style="width:90px;" /></td>
                            </tr>
                            <tr>
                                <th><?php echo esc_html__('Weekday (UTC)', 'wp-seo-next'); ?></th>
                                <td>
                                    <?php
                                    $days = [
                                        0 => __('Sunday', 'wp-seo-next'),
                                        1 => __('Monday', 'wp-seo-next'),
                                        2 => __('Tuesday', 'wp-seo-next'),
                                        3 => __('Wednesday', 'wp-seo-next'),
                                        4 => __('Thursday', 'wp-seo-next'),
                                        5 => __('Friday', 'wp-seo-next'),
                                        6 => __('Saturday', 'wp-seo-next'),
                                    ];
                                    ?>
                                    <select name="weekday_utc">
                                        <?php foreach ($days as $k => $label) : ?>
                                            <option value="<?php echo (int) $k; ?>" <?php selected($weekdayUtc, (int) $k); ?>><?php echo esc_html($label); ?></option>
                                        <?php endforeach; ?>
                                    </select>
                                    <span class="description" style="margin-left:8px;">
                                        <?php echo esc_html__('Used only for Weekly schedule.', 'wp-seo-next'); ?>
                                    </span>
                                </td>
                            </tr>
                            <tr>
                                <th><?php echo esc_html__('Scheduled scan options', 'wp-seo-next'); ?></th>
                                <td>
                                    <label style="margin-right:14px;"><input type="checkbox" name="include_terms" value="1" <?php checked($autoIncludeTerms, true); ?> /> <?php echo esc_html__('Include terms/taxonomies', 'wp-seo-next'); ?></label>
                                    <label><input type="checkbox" name="check_social_image" value="1" <?php checked($autoCheckSocial, true); ?> /> <?php echo esc_html__('Check social image (OG/Twitter)', 'wp-seo-next'); ?></label>
                                </td>
                            </tr>

                            <tr>
                                <th><?php echo esc_html__('Automation safety', 'wp-seo-next'); ?></th>
                                <td>
                                    <label style="margin-right:14px;">
                                        <input type="checkbox" name="smart_window" value="1" <?php checked($autoSmartWindow, true); ?> />
                                        <?php echo esc_html__('Defer run when queue is busy (recommended)', 'wp-seo-next'); ?>
                                    </label>

                                    <label style="margin-right:14px;">
                                        <?php echo esc_html__('Max pending jobs', 'wp-seo-next'); ?>
                                        <input type="number" name="smart_window_max_pending" value="<?php echo (int) $autoMaxPending; ?>" min="0" max="5000" style="width:110px;" />
                                    </label>

                                    <label>
                                        <?php echo esc_html__('Scheduled scan mode', 'wp-seo-next'); ?>
                                        <select name="scan_mode">
                                            <option value="normal" <?php selected($autoScanMode, 'normal'); ?>><?php echo esc_html__('Normal', 'wp-seo-next'); ?></option>
                                            <option value="light" <?php selected($autoScanMode, 'light'); ?>><?php echo esc_html__('Light (smaller batches)', 'wp-seo-next'); ?></option>
                                            <option value="fast" <?php selected($autoScanMode, 'fast'); ?>><?php echo esc_html__('Fast (larger batches)', 'wp-seo-next'); ?></option>
                                            
                                        </select>
                                    </label>

                                    <p class="description" style="margin:6px 0 0;">
                                        <?php echo esc_html__('Light mode reduces batch sizes and dispatcher enqueue bursts to lower the chance of 503 on shared hosting. Fast mode does the opposite: larger batches for quicker completion (use with caution).', 'wp-seo-next'); ?>
                                    </p>
                                </td>
                            </tr>
                            <tr>
                                <th><?php echo esc_html__('Retention (scans)', 'wp-seo-next'); ?></th>
                                <td>
                                    <input type="number" name="history_keep_scans" value="<?php echo (int) $keepScans; ?>" min="0" max="500" style="width:110px;" />
                                    <p class="description" style="margin:6px 0 0;">
                                        <?php echo esc_html__('0 = keep forever. Old scans are purged on scan finalize.', 'wp-seo-next'); ?>
                                    </p>
                                </td>
                            </tr>
                            <tr>
                                <th><?php echo esc_html__('WP-Cron tick', 'wp-seo-next'); ?></th>
                                <td>
                                    <?php
                                    if ($nextTick) {
                                        echo esc_html(sprintf(__('Next tick: %s', 'wp-seo-next'), date_i18n(get_option('date_format') . ' ' . get_option('time_format'), (int) $nextTick)));
                                    } else {
                                        echo esc_html__('Not scheduled (schedule is Off).', 'wp-seo-next');
                                    }
                                    ?>
                                </td>
                            </tr>
                        </tbody>
                    </table>

                    <p style="margin-top:10px;">
                        <?php submit_button(__('Save automation settings', 'wp-seo-next'), 'primary', 'submit', false); ?>
                    </p>
                </form>
            </div>

            <h2><?php echo esc_html__('Issues', 'wp-seo-next'); ?></h2>

            <form method="get" action="<?php echo esc_url(admin_url('tools.php')); ?>" style="margin: 12px 0; display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
                <input type="hidden" name="page" value="wpsn-audit" />
                <select name="scan_id">
                    <option value="0" <?php selected($scanId, 0); ?>><?php echo esc_html__('Latest (current)', 'wp-seo-next'); ?></option>
                    <?php foreach ($scanItems as $srow) :
                        $sid = (int) ($srow['id'] ?? 0);
                        $finished = (string) ($srow['finished_at'] ?? '');
                        $issuesT = (int) ($srow['issues_total'] ?? 0);
                        $status = (string) ($srow['status'] ?? '');
                        $label = '#' . $sid;
                        if ($finished !== '') {
                            $ts = strtotime($finished);
                            if ($ts) {
                                $label .= ' — ' . date_i18n('Y-m-d H:i', $ts);
                            }
                        }
                        if ($status !== '') {
                            $label .= ' — ' . ucfirst($status);
                        }
                        $label .= ' — ' . sprintf(__('Issues: %d', 'wp-seo-next'), $issuesT);
                        ?>
                        <option value="<?php echo (int) $sid; ?>" <?php selected($scanId, $sid); ?>><?php echo esc_html($label); ?></option>
                    <?php endforeach; ?>
                </select>
                <?php if (!empty($filters['object_id'])) : ?>
                    <input type="hidden" name="object_id" value="<?php echo (int) $filters['object_id']; ?>" />
                <?php endif; ?>

                <input type="search" name="s" value="<?php echo esc_attr($filters['s']); ?>" placeholder="<?php echo esc_attr__('Search by title…', 'wp-seo-next'); ?>" />

                <select name="issue_code">
                    <?php foreach ($issueOptions as $code => $label) :
                        $count = $code !== '' && isset($counts[$code]) ? (int) $counts[$code] : 0;
                        $suffix = $code !== '' ? ' (' . $count . ')' : '';
                        ?>
                        <option value="<?php echo esc_attr((string) $code); ?>" <?php selected($filters['issue_code'], (string) $code); ?>><?php echo esc_html($label . $suffix); ?></option>
                    <?php endforeach; ?>
                </select>

                <select name="severity">
                    <?php foreach ($sevOptions as $sev => $label) : ?>
                        <option value="<?php echo esc_attr((string) $sev); ?>" <?php selected((int) $filters['severity'], (int) $sev); ?>><?php echo esc_html($label); ?></option>
                    <?php endforeach; ?>
                </select>

                <select name="object_type">
                    <?php foreach ($typeOptions as $t => $label) : ?>
                        <option value="<?php echo esc_attr((string) $t); ?>" <?php selected($filters['object_type'], (string) $t); ?>><?php echo esc_html($label); ?></option>
                    <?php endforeach; ?>
                </select>

                <?php submit_button(__('Filter', 'wp-seo-next'), 'secondary', false, false); ?>

                <?php
                $exportUrl = $this->buildExportUrl('csv', (int) $scanId, $filters);
                $exportJsonUrl = $this->buildExportUrl('json', (int) $scanId, $filters);
                ?>
                <a class="button" href="<?php echo esc_url($exportUrl); ?>"><?php echo esc_html__('Export CSV', 'wp-seo-next'); ?></a>
                <a class="button" href="<?php echo esc_url($exportJsonUrl); ?>"><?php echo esc_html__('Export JSON', 'wp-seo-next'); ?></a>

                <?php if (!empty($filters['object_id'])) : ?>
                    <span class="description" style="margin-left:6px;">
                        <?php echo esc_html(sprintf(__('Filtered to object #%d', 'wp-seo-next'), (int) $filters['object_id'])); ?>
                    </span>
                <?php endif; ?>

                <span style="margin-left:auto; color:#646970;">
                    <?php echo esc_html(sprintf(__('Total: %d', 'wp-seo-next'), $total)); ?>
                </span>
            </form>

            <div style="margin: 12px 0; padding: 12px; background: #fff; border: 1px solid #ccd0d4; border-radius: 6px;">
                <strong><?php echo esc_html__('Bulk actions for current filter', 'wp-seo-next'); ?></strong>
                <div class="description" style="margin-top:4px;">
                    <?php echo esc_html__('This queues background jobs for all distinct objects matched by the current filters (up to the limit).', 'wp-seo-next'); ?>
                </div>

                <?php
                $defaultOnlyIfEmpty = (strpos((string) $filters['issue_code'], 'missing_') === 0);
                $queueSettings = (new SettingsRepository())->getAll();
                $queueMode = sanitize_key((string) ($queueSettings['performance']['queue']['mode'] ?? 'direct'));
                $directMaxJobs = max(1, (int) ($queueSettings['performance']['queue']['direct']['max_jobs'] ?? 200));
                $bulkRevisionsUrl = admin_url('tools.php?page=wpsn-revisions&source=bulk');
                $bulkLimitValue = isset($_GET['bulk_limit']) ? max(1, min(5000, (int) $_GET['bulk_limit'])) : 500;
                ?>

                <form method="post" action="<?php echo esc_url($actionUrl); ?>" style="margin-top:10px; display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
                    <input type="hidden" name="action" value="wpsn_audit_bulk_action" />
                    <?php wp_nonce_field('wpsn_audit_bulk_action', 'wpsn_nonce'); ?>
                    <input type="hidden" name="scan_id" value="<?php echo (int) $scanId; ?>" />

                    <input type="hidden" name="s" value="<?php echo esc_attr((string) $filters['s']); ?>" />
                    <input type="hidden" name="issue_code" value="<?php echo esc_attr((string) $filters['issue_code']); ?>" />
                    <input type="hidden" name="severity" value="<?php echo esc_attr((string) (int) $filters['severity']); ?>" />
                    <input type="hidden" name="object_type" value="<?php echo esc_attr((string) $filters['object_type']); ?>" />
                    <input type="hidden" name="object_id" value="<?php echo esc_attr((string) (int) $filters['object_id']); ?>" />

                    <label>
                        <?php echo esc_html__('Action', 'wp-seo-next'); ?>
                        <select name="bulk_action">
                            <option value="generate_local"><?php echo esc_html__('Generate local meta suggestions (draft)', 'wp-seo-next'); ?></option>
                            <option value="generate_apply_local"><?php echo esc_html__('Generate + apply local meta suggestions', 'wp-seo-next'); ?></option>
                            <option value="clear_canonical"><?php echo esc_html__('Clear canonical override', 'wp-seo-next'); ?></option>
                            <option value="clear_noindex"><?php echo esc_html__('Clear noindex override', 'wp-seo-next'); ?></option>
                        </select>
                    </label>

                    <label>
                        <?php echo esc_html__('Limit', 'wp-seo-next'); ?>
                        <input type="number" name="limit" value="<?php echo (int) $bulkLimitValue; ?>" min="1" max="5000" style="width:90px;" />
                    </label>

                    <label>
                        <?php echo esc_html__('Continue queue every', 'wp-seo-next'); ?>
                        <select name="live_profile">
                            <option value="fast"><?php echo esc_html__('10 sec', 'wp-seo-next'); ?></option>
                            <option value="relaxed"><?php echo esc_html__('30 sec', 'wp-seo-next'); ?></option>
                            <option value="minute"><?php echo esc_html__('1 min', 'wp-seo-next'); ?></option>
                        </select>
                    </label>

                    <label>
                        <input type="checkbox" name="only_if_empty" value="1" <?php checked($defaultOnlyIfEmpty); ?> />
                        <?php echo esc_html__('Only if empty or broken (for meta suggestions)', 'wp-seo-next'); ?>
                    </label>

                    <label>
                        <input type="checkbox" name="overwrite_suggestions" value="1" />
                        <?php echo esc_html__('Overwrite existing drafts', 'wp-seo-next'); ?>
                    </label>

                    <?php submit_button(__('Queue action', 'wp-seo-next'), 'primary', 'submit', false); ?>

                    <span style="color:#646970; margin-left:8px;">
                        <?php echo esc_html(sprintf(__('Matched issues: %d', 'wp-seo-next'), (int) $total)); ?>
                    </span>
                </form>

                <div style="margin-top:8px; color:#646970; font-size:12px;">
                    <?php echo esc_html__('Limit counts distinct objects to queue, not jobs per request. Generate + apply creates two jobs per object.', 'wp-seo-next'); ?>
                    <?php if ($queueMode === 'direct') : ?>
                        <?php echo ' ' . esc_html(sprintf(__('Direct mode currently auto-runs up to %d jobs in the request that starts the queue; the live worker continues the rest while this page stays open.', 'wp-seo-next'), $directMaxJobs)); ?>
                    <?php endif; ?>
                </div>
                <div style="margin-top:4px; color:#646970; font-size:12px;">
                    <?php echo esc_html__('Tip: Use filters above (Issue/Severity/Type) to narrow scope before running bulk actions. Already applied suggestions can be regenerated; only existing drafts are skipped unless you tick overwrite.', 'wp-seo-next'); ?>
                </div>
                <div style="margin-top:4px; color:#646970; font-size:12px;">
                    <?php echo wp_kses_post(sprintf(__('Every applied SEO change is auto-backed up in <a href="%s">Revisions</a> (Source: Bulk), so you can roll back if the result is not good.', 'wp-seo-next'), esc_url($bulkRevisionsUrl))); ?>
                </div>
            </div>

            
            <div style="margin: 12px 0; padding: 12px; background: #fff; border: 1px solid #ccd0d4; border-radius: 6px;">
                <strong><?php echo esc_html__('Bulk actions for selected rows', 'wp-seo-next'); ?></strong>
                <div class="description" style="margin-top:4px;">
                    <?php echo esc_html__('Select specific rows below and queue an action only for those objects (deduplicated).', 'wp-seo-next'); ?>
                </div>

                <form id="wpsn-audit-selected-form" method="post" action="<?php echo esc_url($actionUrl); ?>" style="margin-top:10px; display:flex; gap:10px; align-items:center; flex-wrap:wrap;">
                    <input type="hidden" name="action" value="wpsn_audit_bulk_selected_action" />
                    <?php wp_nonce_field('wpsn_audit_bulk_selected_action', 'wpsn_nonce'); ?>
                    <input type="hidden" name="scan_id" value="<?php echo (int) $scanId; ?>" />

                    <input type="hidden" name="s" value="<?php echo esc_attr((string) $filters['s']); ?>" />
                    <input type="hidden" name="issue_code" value="<?php echo esc_attr((string) $filters['issue_code']); ?>" />
                    <input type="hidden" name="severity" value="<?php echo esc_attr((string) (int) $filters['severity']); ?>" />
                    <input type="hidden" name="object_type" value="<?php echo esc_attr((string) $filters['object_type']); ?>" />
                    <input type="hidden" name="object_id" value="<?php echo esc_attr((string) (int) $filters['object_id']); ?>" />

                    <label>
                        <?php echo esc_html__('Action', 'wp-seo-next'); ?>
                        <select name="bulk_action">
                            <option value="generate_local"><?php echo esc_html__('Generate local meta suggestions (draft)', 'wp-seo-next'); ?></option>
                            <option value="generate_apply_local"><?php echo esc_html__('Generate + apply local meta suggestions', 'wp-seo-next'); ?></option>
                            <option value="clear_canonical"><?php echo esc_html__('Clear canonical override', 'wp-seo-next'); ?></option>
                            <option value="clear_noindex"><?php echo esc_html__('Clear noindex override', 'wp-seo-next'); ?></option>
                        </select>
                    </label>

                    <label>
                        <?php echo esc_html__('Continue queue every', 'wp-seo-next'); ?>
                        <select name="live_profile">
                            <option value="fast"><?php echo esc_html__('10 sec', 'wp-seo-next'); ?></option>
                            <option value="relaxed"><?php echo esc_html__('30 sec', 'wp-seo-next'); ?></option>
                            <option value="minute"><?php echo esc_html__('1 min', 'wp-seo-next'); ?></option>
                        </select>
                    </label>

                    <label>
                        <input type="checkbox" name="only_if_empty" value="1" <?php checked($defaultOnlyIfEmpty); ?> />
                        <?php echo esc_html__('Only if empty or broken (for meta suggestions)', 'wp-seo-next'); ?>
                    </label>

                    <label>
                        <input type="checkbox" name="overwrite_suggestions" value="1" />
                        <?php echo esc_html__('Overwrite existing drafts', 'wp-seo-next'); ?>
                    </label>

                    <?php submit_button(__('Queue for selected', 'wp-seo-next'), 'primary', 'submit', false); ?>

                    <span id="wpsn-audit-selected-count" style="color:#646970; margin-left:8px;">0</span>
                </form>
                <div style="margin-top:4px; color:#646970; font-size:12px;">
                    <?php echo esc_html__('Selected-row runs use the same safe backup path before applying changes.', 'wp-seo-next'); ?>
                </div>
            </div>

            <?php $this->renderRecentBulkReports(); ?>

            <table class="widefat striped">
                <thead>
                    <tr>
                        <th style="width:28px; text-align:center;"><input type="checkbox" id="wpsn-audit-select-all" title="<?php echo esc_attr__('Select all', 'wp-seo-next'); ?>" /></th>
                        <th style="width:90px;"><?php echo esc_html__('Severity', 'wp-seo-next'); ?></th>
                        <th style="width:200px;"><?php echo esc_html__('Issue', 'wp-seo-next'); ?></th>
                        <th><?php echo esc_html__('Page', 'wp-seo-next'); ?></th>
                        <th><?php echo esc_html__('Suggestion', 'wp-seo-next'); ?></th>
                        <th style="width:150px;"><?php echo esc_html__('Detected', 'wp-seo-next'); ?></th>
                        <th style="width:120px;"><?php echo esc_html__('Actions', 'wp-seo-next'); ?></th>
                    </tr>
                </thead>
                <tbody>
                <?php if (empty($items)) : ?>
                    <tr>
                        <td colspan="7">
                            <?php echo esc_html__('No issues found. Run a scan to populate the list.', 'wp-seo-next'); ?>
                        </td>
                    </tr>
                <?php else : ?>
                    <?php foreach ($items as $row) :
                        $severity = (int) ($row['severity'] ?? 0);
                        $sevLabel = $this->severityLabel($severity);
                        $sevStyle = $this->severityStyle($severity);

                        $issueCode = (string) ($row['issue_code'] ?? '');

                        $objType = (string) ($row['object_type'] ?? '');
                        $objId   = (int) ($row['object_id'] ?? 0);
                        $subType = (string) ($row['object_subtype'] ?? '');

                        $title = (string) ($row['object_title'] ?? '');
                        $url   = (string) ($row['object_url'] ?? '');

                        $message = (string) ($row['message'] ?? '');
                        $suggest = (string) ($row['suggestion'] ?? '');
                        $detected = (string) ($row['detected_at'] ?? '');

                        $editLink = '';
                        if ($objType === 'post') {
                            $editLink = get_edit_post_link($objId, '');
                        } elseif ($objType === 'term') {
                            $editLink = get_edit_term_link($objId, $subType);
                        }

                        ?>
                        <tr>
                            <td style="text-align:center;">
                                <?php if (in_array($objType, ['post', 'term'], true) && $objId > 0) :
                                    $selVal = $objType . '|' . (int) $objId . '|' . $subType . '|' . $issueCode;
                                    ?>
                                    <input class="wpsn-audit-row-check" type="checkbox" name="selected[]" value="<?php echo esc_attr($selVal); ?>" form="wpsn-audit-selected-form" />
                                <?php else : ?>
                                    <span style="color:#646970;">—</span>
                                <?php endif; ?>
                            </td>
                            <td><span style="<?php echo esc_attr($sevStyle); ?>"><?php echo esc_html($sevLabel); ?></span></td>
                            <td>
                                <strong><?php echo esc_html($message); ?></strong>
                                <div style="color:#646970; font-size:12px; margin-top:2px;">
                                    <?php
                                    if ($objType === 'post') {
                                        echo esc_html(__('Post', 'wp-seo-next'));
                                    } elseif ($objType === 'term') {
                                        echo esc_html(__('Term', 'wp-seo-next'));
                                    } else {
                                        echo esc_html(__('System', 'wp-seo-next'));
                                    }
                                    if ($subType !== '') {
                                        echo ' · ' . esc_html($subType);
                                    }
                                    ?>
                                </div>
                            </td>
                            <td>
                                <?php if ($url !== '') : ?>
                                    <a href="<?php echo esc_url($url); ?>" target="_blank" rel="noopener noreferrer">
                                        <?php echo esc_html($title !== '' ? $title : $url); ?>
                                    </a>
                                <?php else : ?>
                                    <?php echo esc_html($title); ?>
                                <?php endif; ?>
                                <div style="color:#646970; font-size:12px; margin-top:2px;">
                                    <?php echo esc_html($url); ?>
                                </div>
                            </td>
                            <td><?php echo esc_html($suggest); ?></td>
                            <td><?php echo esc_html($detected); ?></td>
                            <td>
                                <?php if (is_string($editLink) && $editLink !== '') : ?>
                                    <a class="button button-small" href="<?php echo esc_url($editLink); ?>"><?php echo esc_html__('Edit', 'wp-seo-next'); ?></a>
                                <?php endif; ?>
                                <?php if ($url !== '') : ?>
                                    <?php $viewLabel = ($objType === 'system' && $subType === 'sitemap') ? __('Open sitemap', 'wp-seo-next') : __('View', 'wp-seo-next'); ?>
                                    <a class="button button-small" href="<?php echo esc_url($url); ?>" target="_blank" rel="noopener noreferrer"><?php echo esc_html($viewLabel); ?></a>
                                <?php endif; ?>

                                <?php
                                // Quick one-click fixes (queued).
                                $returnUrl = add_query_arg(array_merge(['page' => 'wpsn-audit'], $_GET), admin_url('tools.php'));

                                $canMeta = in_array($objType, ['post', 'term'], true) && $objId > 0;
                                $isPost = ($objType === 'post');

                                $metaIssue = in_array($issueCode, [
                                    AuditRunner::ISSUE_MISSING_TITLE,
                                    AuditRunner::ISSUE_MISSING_DESCRIPTION,
                                    AuditRunner::ISSUE_DUPLICATE_TITLE,
                                    AuditRunner::ISSUE_DUPLICATE_DESCRIPTION,
                                ], true);

                                $canonIssue = in_array($issueCode, [
                                    AuditRunner::ISSUE_CANONICAL_INVALID,
                                    AuditRunner::ISSUE_CANONICAL_EXTERNAL,
                                    AuditRunner::ISSUE_CANONICAL_MISMATCH,
                                ], true);

                                if ($canMeta && $metaIssue) :
                                    ?>
                                    <form method="post" action="<?php echo esc_url($actionUrl); ?>" style="display:inline-block; margin:4px 4px 0 0;">
                                        <input type="hidden" name="action" value="wpsn_audit_quick_action" />
                                        <?php wp_nonce_field('wpsn_audit_quick_action', 'wpsn_nonce'); ?>
                                        <input type="hidden" name="scan_id" value="<?php echo (int) $scanId; ?>" />
                                        <input type="hidden" name="bulk_action" value="generate_local" />
                                        <input type="hidden" name="object_type" value="<?php echo esc_attr($objType); ?>" />
                                        <input type="hidden" name="object_subtype" value="<?php echo esc_attr($subType); ?>" />
                                        <input type="hidden" name="object_id" value="<?php echo (int) $objId; ?>" />
                                        <input type="hidden" name="issue_code" value="<?php echo esc_attr($issueCode); ?>" />
                                        <input type="hidden" name="only_if_empty" value="<?php echo (strpos((string) $issueCode, 'missing_') === 0) ? '1' : '0'; ?>" />
                                        <input type="hidden" name="return_url" value="<?php echo esc_attr($returnUrl); ?>" />
                                        <button class="button button-small" type="submit"><?php echo esc_html__('Gen draft', 'wp-seo-next'); ?></button>
                                    </form>

                                    <form method="post" action="<?php echo esc_url($actionUrl); ?>" style="display:inline-block; margin:4px 4px 0 0;">
                                        <input type="hidden" name="action" value="wpsn_audit_quick_action" />
                                        <?php wp_nonce_field('wpsn_audit_quick_action', 'wpsn_nonce'); ?>
                                        <input type="hidden" name="scan_id" value="<?php echo (int) $scanId; ?>" />
                                        <input type="hidden" name="bulk_action" value="generate_apply_local" />
                                        <input type="hidden" name="object_type" value="<?php echo esc_attr($objType); ?>" />
                                        <input type="hidden" name="object_subtype" value="<?php echo esc_attr($subType); ?>" />
                                        <input type="hidden" name="object_id" value="<?php echo (int) $objId; ?>" />
                                        <input type="hidden" name="issue_code" value="<?php echo esc_attr($issueCode); ?>" />
                                        <input type="hidden" name="only_if_empty" value="<?php echo (strpos((string) $issueCode, 'missing_') === 0) ? '1' : '0'; ?>" />
                                        <input type="hidden" name="return_url" value="<?php echo esc_attr($returnUrl); ?>" />
                                        <button class="button button-small" type="submit" onclick="return confirm('<?php echo esc_js(__('Generate + apply suggestions for this item?', 'wp-seo-next')); ?>');"><?php echo esc_html__('Gen+apply', 'wp-seo-next'); ?></button>
                                    </form>
                                <?php endif; ?>

                                <?php if ($canMeta && $canonIssue) : ?>
                                    <form method="post" action="<?php echo esc_url($actionUrl); ?>" style="display:inline-block; margin:4px 4px 0 0;">
                                        <input type="hidden" name="action" value="wpsn_audit_quick_action" />
                                        <?php wp_nonce_field('wpsn_audit_quick_action', 'wpsn_nonce'); ?>
                                        <input type="hidden" name="scan_id" value="<?php echo (int) $scanId; ?>" />
                                        <input type="hidden" name="bulk_action" value="clear_canonical" />
                                        <input type="hidden" name="object_type" value="<?php echo esc_attr($objType); ?>" />
                                        <input type="hidden" name="object_subtype" value="<?php echo esc_attr($subType); ?>" />
                                        <input type="hidden" name="object_id" value="<?php echo (int) $objId; ?>" />
                                        <input type="hidden" name="issue_code" value="<?php echo esc_attr($issueCode); ?>" />
                                        <input type="hidden" name="return_url" value="<?php echo esc_attr($returnUrl); ?>" />
                                        <button class="button button-small" type="submit" onclick="return confirm('<?php echo esc_js(__('Clear canonical override for this object?', 'wp-seo-next')); ?>');"><?php echo esc_html__('Clear canonical', 'wp-seo-next'); ?></button>
                                    </form>
                                <?php endif; ?>

                                <?php if ($canMeta && $issueCode === AuditRunner::ISSUE_NOINDEX) : ?>
                                    <form method="post" action="<?php echo esc_url($actionUrl); ?>" style="display:inline-block; margin:4px 4px 0 0;">
                                        <input type="hidden" name="action" value="wpsn_audit_quick_action" />
                                        <?php wp_nonce_field('wpsn_audit_quick_action', 'wpsn_nonce'); ?>
                                        <input type="hidden" name="scan_id" value="<?php echo (int) $scanId; ?>" />
                                        <input type="hidden" name="bulk_action" value="clear_noindex" />
                                        <input type="hidden" name="object_type" value="<?php echo esc_attr($objType); ?>" />
                                        <input type="hidden" name="object_subtype" value="<?php echo esc_attr($subType); ?>" />
                                        <input type="hidden" name="object_id" value="<?php echo (int) $objId; ?>" />
                                        <input type="hidden" name="issue_code" value="<?php echo esc_attr($issueCode); ?>" />
                                        <input type="hidden" name="return_url" value="<?php echo esc_attr($returnUrl); ?>" />
                                        <button class="button button-small" type="submit" onclick="return confirm('<?php echo esc_js(__('Clear noindex override for this object?', 'wp-seo-next')); ?>');"><?php echo esc_html__('Clear noindex', 'wp-seo-next'); ?></button>
                                    </form>
                                <?php endif; ?>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                <?php endif; ?>
                </tbody>
            </table>

            <?php
            if ($totalPages > 1) {
                $base = add_query_arg(array_merge($_GET, ['paged' => '%#%']), $pageUrl);
                echo '<div style="margin-top: 12px;">';
                echo wp_kses_post(paginate_links([
                    'base'      => $base,
                    'format'    => '',
                    'current'   => $paged,
                    'total'     => $totalPages,
                    'prev_text' => '«',
                    'next_text' => '»',
                ]));
                echo '</div>';
            }
            ?>
        </div>
        <?php
    }

    /**
     * @param mixed $raw
     * @return array<string,int>
     */
    private function normalizeIssueCounts($raw): array
    {
        if (is_string($raw) && $raw !== '') {
            $decoded = json_decode($raw, true);
            $raw = is_array($decoded) ? $decoded : [];
        }

        if (!is_array($raw)) {
            return [];
        }

        $counts = [];
        foreach ($raw as $code => $count) {
            $code = sanitize_key((string) $code);
            if ($code === '') {
                continue;
            }
            $counts[$code] = (int) $count;
        }
        ksort($counts);

        return $counts;
    }

    /**
     * @param array<string,int> $before
     * @param array<string,int> $after
     * @return array<string,mixed>
     */
    private function issueDeltaData(array $before, array $after): array
    {
        $beforeTotal = 0;
        foreach ($before as $count) {
            $beforeTotal += (int) $count;
        }

        $afterTotal = 0;
        foreach ($after as $count) {
            $afterTotal += (int) $count;
        }

        $changes = [];
        $codes = array_values(array_unique(array_merge(array_keys($before), array_keys($after))));
        foreach ($codes as $code) {
            $delta = (int) ($after[$code] ?? 0) - (int) ($before[$code] ?? 0);
            if ($delta !== 0) {
                $changes[$code] = $delta;
            }
        }

        uasort($changes, static function (int $a, int $b): int {
            $abs = abs($b) <=> abs($a);
            if ($abs !== 0) {
                return $abs;
            }

            return $a <=> $b;
        });

        return [
            'before_total' => $beforeTotal,
            'after_total' => $afterTotal,
            'delta' => $afterTotal - $beforeTotal,
            'changes' => $changes,
        ];
    }

    /**
     * @param array<string,mixed> $meta
     * @param array<string,mixed> $summary
     * @return array<string,mixed>
     */
    private function bulkLiveDeltaContext(array $meta, array $summary = []): array
    {
        $beforeSnapshot = isset($meta['live_snapshot_before']) && is_array($meta['live_snapshot_before']) ? $meta['live_snapshot_before'] : [];
        $afterSnapshot = isset($meta['live_snapshot_after']) && is_array($meta['live_snapshot_after']) ? $meta['live_snapshot_after'] : [];

        $beforeCounts = $this->normalizeIssueCounts($beforeSnapshot['counts_by_code'] ?? []);
        $afterCounts = $this->normalizeIssueCounts($afterSnapshot['counts_by_code'] ?? []);

        $hasBefore = !empty($beforeSnapshot);
        $hasAfter = !empty($afterSnapshot);

        if (!$hasBefore && !$hasAfter) {
            return [];
        }

        $beforeTotal = $hasBefore && array_key_exists('total', $beforeSnapshot)
            ? (int) $beforeSnapshot['total']
            : array_sum($beforeCounts);
        $afterTotal = $hasAfter && array_key_exists('total', $afterSnapshot)
            ? (int) $afterSnapshot['total']
            : array_sum($afterCounts);

        if (!$hasAfter) {
            $changedObjects = (int) (($summary['applied'] ?? 0) + ($summary['fix_applied'] ?? 0));
            if ($changedObjects <= 0) {
                return [
                    'label' => __('Live issues Δ 0 · live issue list did not need a refresh.', 'wp-seo-next'),
                    'details' => '',
                ];
            }

            return [
                'label' => __('Live issues delta will appear after the audit refresh jobs finish.', 'wp-seo-next'),
                'details' => '',
            ];
        }

        $deltaData = $this->issueDeltaData($beforeCounts, $afterCounts);
        $details = $this->formatIssueDeltaHighlights((array) ($deltaData['changes'] ?? []));

        return [
            'label' => sprintf(
                __('Live issues Δ %1$s · %2$d → %3$d', 'wp-seo-next'),
                $this->formatSignedNumber((int) ($deltaData['delta'] ?? ($afterTotal - $beforeTotal))),
                $beforeTotal,
                $afterTotal
            ),
            'details' => $details,
        ];
    }

    /**
     * @param array<string,int> $changes
     */
    private function formatIssueDeltaHighlights(array $changes, int $limit = 3): string
    {
        if (empty($changes)) {
            return '';
        }

        $parts = [];
        $shown = 0;
        foreach ($changes as $code => $delta) {
            $parts[] = sprintf(
                '%1$s %2$s',
                $this->issueCodeLabel((string) $code),
                $this->formatSignedNumber((int) $delta)
            );
            $shown++;
            if ($shown >= $limit) {
                break;
            }
        }

        return implode(' · ', $parts);
    }

    private function formatSignedNumber(int $number): string
    {
        if ($number > 0) {
            return '+' . $number;
        }
        if ($number < 0) {
            return (string) $number;
        }
        return '0';
    }

    private function issueCodeLabel(string $code): string
    {
        $code = sanitize_key($code);
        if ($code === '') {
            return __('Issues', 'wp-seo-next');
        }

        $label = str_replace('_', ' ', $code);
        $label = preg_replace('/\s+/', ' ', (string) $label) ?: $label;

        return ucfirst(trim((string) $label));
    }


    private function severityLabel(int $severity): string
    {
        if ($severity >= AuditRunner::SEVERITY_HIGH) {
            return __('High', 'wp-seo-next');
        }
        if ($severity === AuditRunner::SEVERITY_MEDIUM) {
            return __('Medium', 'wp-seo-next');
        }
        return __('Low', 'wp-seo-next');
    }

    private function severityStyle(int $severity): string
    {
        // Keep simple inline styles (no external assets).
        if ($severity >= AuditRunner::SEVERITY_HIGH) {
            return 'display:inline-block;padding:2px 8px;border-radius:999px;background:#fbeaea;color:#b32d2e;font-weight:600;';
        }
        if ($severity === AuditRunner::SEVERITY_MEDIUM) {
            return 'display:inline-block;padding:2px 8px;border-radius:999px;background:#fff8e5;color:#8a6d00;font-weight:600;';
        }
        return 'display:inline-block;padding:2px 8px;border-radius:999px;background:#eef7ff;color:#0a4b78;font-weight:600;';
    }
}
