<?php

namespace WPSN\Modules\Diagnostics\Admin;

use WPSN\Admin\SettingsRepository;
use WPSN\Core\Capabilities;
use WPSN\Core\Container;
use WPSN\Core\Queue\QueueAutoPause;
use WPSN\Core\Queue\QueuePause;
use WPSN\Core\Queue\QueueRemoteRunnerConfig;
use WPSN\Modules\Bulk\BulkQueueSessionRepository;
use WPSN\Modules\Bulk\BulkSessionReportRepository;
use WPSN\Modules\Diagnostics\QueueLiveRunnerProfiles;

defined('ABSPATH') || exit;

/**
 * Queue Center
 *
 * Operational UI to inspect and control the internal WPDB queue.
 *
 * Why it exists:
 * - shared hosting often throws 503 when a manual worker tries to run too much
 * - admins need a safe way to pause background jobs during incidents
 * - support needs visibility into "what is stuck" without SSH access
 */
final class QueueAdmin
{
    public const PAGE_SLUG = 'wpsn-queue';
    public const LIVE_NONCE_ACTION = 'wpsn_queue_live';

    private Container $container;

    private string $hookSuffix = '';

    public function __construct(Container $container)
    {
        $this->container = $container;
    }

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

        add_action('admin_post_wpsn_queue_run', [$this, 'handleRun']);
        add_action('admin_post_wpsn_queue_run_batch', [$this, 'handleRunBatch']);
        add_action('admin_post_wpsn_queue_pause', [$this, 'handlePause']);
        add_action('admin_post_wpsn_queue_resume', [$this, 'handleResume']);
        add_action('admin_post_wpsn_queue_clear_failed', [$this, 'handleClearFailed']);
        add_action('admin_post_wpsn_queue_requeue_failed', [$this, 'handleRequeueFailed']);
        add_action('admin_post_wpsn_queue_release_stale', [$this, 'handleReleaseStale']);
        add_action('admin_post_wpsn_queue_postpone_job', [$this, 'handlePostponeJob']);
        add_action('admin_post_wpsn_queue_force_requeue', [$this, 'handleForceRequeueJob']);
        add_action('admin_post_wpsn_queue_delete_job', [$this, 'handleDeleteJob']);
        add_action('admin_post_wpsn_queue_autopause_clear', [$this, 'handleAutoPauseClear']);
        add_action('admin_post_wpsn_bulk_session_export', [$this, 'handleBulkSessionExport']);

        add_action('admin_post_wpsn_queue_live_status', [$this, 'handleLiveStatus']);
        add_action('admin_post_wpsn_queue_live_tick', [$this, 'handleLiveTick']);

        // Remote runner settings (external cron URL).
        add_action('admin_post_wpsn_queue_remote_update', [$this, 'handleRemoteUpdate']);
    }

    public function addMenu(): void
    {
        $this->hookSuffix = (string) add_management_page(
            __('Queue Center', 'wp-seo-next'),
            __('Queue', 'wp-seo-next'),
            Capabilities::CAP_TOOLS,
            self::PAGE_SLUG,
            [$this, 'render']
        );
    }

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

        if ($this->hookSuffix === '' && (!isset($_GET['page']) || (string) wp_unslash($_GET['page']) !== self::PAGE_SLUG)) {
            return;
        }

        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(self::LIVE_NONCE_ACTION),
            'defaultProfile' => QueueLiveRunnerProfiles::DEFAULT_PROFILE,
            'profiles' => QueueLiveRunnerProfiles::clientConfig(),
            'i18n' => [
                'liveWorker' => __('Live worker', 'wp-seo-next'),
                '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'),
                'statusErrorDetailed' => __('Live update failed ({reason}). Retrying in {seconds}s…', 'wp-seo-next'),
                'invalidJson' => __('invalid JSON response', 'wp-seo-next'),
                'start' => __('Start live run', 'wp-seo-next'),
                'stop' => __('Pause live run', 'wp-seo-next'),
                'lastUpdated' => __('Last updated just now.', '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'),
                'profileSummary' => __('{label}: every {seconds}s · up to {jobs} jobs / cycle.', 'wp-seo-next'),
            ],
        ]);
    }

    /**
     * @param array<string,string> $args
     */
    private function redirect(array $args = []): void
    {
        $url = admin_url('tools.php?page=' . self::PAGE_SLUG);
        $viewArgs = $this->currentViewArgs();
        if (!empty($viewArgs)) {
            $url = add_query_arg($viewArgs, $url);
        }
        if (!empty($args)) {
            $url = add_query_arg($args, $url);
        }
        wp_safe_redirect($url);
        exit;
    }

    /**
     * @return array<string,string>
     */
    private function currentViewArgs(): array
    {
        $args = [];

        $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : '';
        if ($status !== '') {
            $args['status'] = $status;
        }

        $jobType = isset($_GET['job_type']) ? sanitize_text_field((string) wp_unslash($_GET['job_type'])) : '';
        if ($jobType !== '') {
            $args['job_type'] = $jobType;
        }

        $bulkSession = isset($_GET['bulk_session']) ? sanitize_key((string) wp_unslash($_GET['bulk_session'])) : '';
        if ($bulkSession !== '') {
            $args['bulk_session'] = $bulkSession;
        }

        $paged = isset($_GET['paged']) ? (int) $_GET['paged'] : 1;
        if ($paged > 1) {
            $args['paged'] = (string) $paged;
        }

        return $args;
    }

    /**
     * @return array<string,mixed>
     */
    private function liveProfileConfig(string $profile): array
    {
        return QueueLiveRunnerProfiles::definition($profile);
    }

    /**
     * @return array{processed:int,elapsed_ms:int}
     */
    private function runWorkerLoop(int $maxJobs, int $maxSeconds): array
    {
        $maxJobs = max(1, min(100, $maxJobs));
        $maxSeconds = max(3, min(20, $maxSeconds));

        /** @var \WPSN\Core\Queue\QueueRunner $runner */
        $runner = $this->container->get('queue_runner');

        $processed = 0;
        $started = microtime(true);
        try {
            while ($processed < $maxJobs && (microtime(true) - $started) < $maxSeconds) {
                $remaining = $maxJobs - $processed;
                $chunk = max(1, min(5, $remaining));
                $step = (int) $runner->run($chunk);
                if ($step < 1) {
                    break;
                }
                $processed += $step;
            }
        } catch (\Throwable $e) {
            // Ignore and return the progress we already made.
        }

        return [
            'processed' => max(0, $processed),
            'elapsed_ms' => max(0, (int) round((microtime(true) - $started) * 1000)),
        ];
    }

    /**
     * @return array<string,mixed>
     */
    private function buildLivePayload(string $sessionId = ''): array
    {
        /** @var \WPSN\Core\Queue\QueueInterface $queue */
        $queue = $this->container->get('queue');
        $counts = [];
        try {
            $counts = $queue->counts();
        } catch (\Throwable $e) {
            $counts = ['pending' => 0, 'running' => 0, 'done' => 0, 'failed' => 0];
        }

        /** @var \WPSN\Core\Settings $settings */
        $settings = $this->container->get('settings');
        $queueMode = sanitize_key((string) $settings->get('performance.queue.mode', 'auto'));

        global $wpdb;
        $table = isset($wpdb) && is_object($wpdb) && property_exists($wpdb, 'prefix') ? ($wpdb->prefix . 'wpsn_queue_jobs') : 'wpsn_queue_jobs';
        $metrics = $this->runtimeMetrics($table, $queueMode);

        $payload = [
            'queue_mode' => $queueMode !== '' ? $queueMode : 'auto',
            'paused' => QueuePause::isPaused(),
            'pause_until' => QueuePause::pauseUntil(),
            'server_time' => time(),
            'counts' => [
                'pending' => (int) ($counts['pending'] ?? 0),
                'running' => (int) ($counts['running'] ?? 0),
                'failed' => (int) ($counts['failed'] ?? 0),
                'done' => (int) ($counts['done'] ?? 0),
            ],
            'metrics' => $metrics,
        ];

        $sessionId = sanitize_key($sessionId);
        if ($sessionId !== '') {
            $repo = new BulkQueueSessionRepository($this->container->get('logger'));
            $session = $repo->get($sessionId);
            $progress = $repo->progress($sessionId);
            if (!empty($session) && !empty($progress)) {
                $payload['session'] = [
                    'exists' => true,
                    'session_id' => $sessionId,
                    'action' => (string) ($session['action'] ?? ''),
                    'action_label' => $repo->actionLabel((string) ($session['action'] ?? '')),
                    'targets' => (int) ($session['targets'] ?? 0),
                    'eligible_targets' => (int) ($session['eligible_targets'] ?? 0),
                    'queued_jobs' => (int) ($progress['queued_jobs'] ?? 0),
                    'pending' => (int) ($progress['pending'] ?? 0),
                    'running' => (int) ($progress['running'] ?? 0),
                    'failed' => (int) ($progress['failed'] ?? 0),
                    'done' => (int) ($progress['done'] ?? 0),
                    'terminal' => (int) ($progress['terminal'] ?? 0),
                    'percent' => (int) ($progress['percent'] ?? 0),
                    'active' => !empty($progress['active']),
                    'complete' => ((int) ($progress['pending'] ?? 0) + (int) ($progress['running'] ?? 0)) < 1 && (int) ($progress['queued_jobs'] ?? 0) > 0,
                    'last_error' => (string) ($progress['last_error'] ?? ''),
                    'updated_at' => (int) ($session['updated_at'] ?? 0),
                ];
            } else {
                $payload['session'] = [
                    'exists' => false,
                    'session_id' => $sessionId,
                ];
            }
        }

        return $payload;
    }

    private function verifyLiveRequest(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            $this->sendLiveJsonError(__('You do not have permission to perform this action.', 'wp-seo-next'), 403);
        }

        $nonce = isset($_POST['nonce']) ? (string) wp_unslash($_POST['nonce']) : '';
        if (!wp_verify_nonce($nonce, self::LIVE_NONCE_ACTION)) {
            $this->sendLiveJsonError(__('Invalid live worker nonce.', 'wp-seo-next'), 403);
        }
    }

    /**
     * @param array<string,mixed> $payload
     */
    private function sendLiveJsonSuccess(array $payload): void
    {
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        nocache_headers();
        wp_send_json_success($payload);
    }

    /**
     * @param array<string,mixed> $extra
     */
    private function sendLiveJsonError(string $message, int $status = 500, array $extra = []): void
    {
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        nocache_headers();
        wp_send_json_error(array_merge(['message' => $message], $extra), max(400, min(599, $status)));
    }

    public function handleLiveStatus(): void
    {
        $this->verifyLiveRequest();

        $sessionId = isset($_POST['session_id']) ? sanitize_key((string) wp_unslash($_POST['session_id'])) : '';
        $this->sendLiveJsonSuccess([
            'status' => $this->buildLivePayload($sessionId),
        ]);
    }

    public function handleLiveTick(): void
    {
        $this->verifyLiveRequest();

        $sessionId = isset($_POST['session_id']) ? sanitize_key((string) wp_unslash($_POST['session_id'])) : '';
        $profile = isset($_POST['profile']) ? sanitize_key((string) wp_unslash($_POST['profile'])) : QueueLiveRunnerProfiles::DEFAULT_PROFILE;
        $config = $this->liveProfileConfig($profile);

        if (QueuePause::isPaused()) {
            $this->sendLiveJsonSuccess([
                'profile' => (string) ($config['key'] ?? QueueLiveRunnerProfiles::DEFAULT_PROFILE),
                'processed' => 0,
                'elapsed_ms' => 0,
                'ran' => false,
                'status' => $this->buildLivePayload($sessionId),
            ]);
        }

        $processed = 0;
        $elapsedMs = 0;
        if (!empty($config['auto_run'])) {
            $loop = $this->runWorkerLoop((int) ($config['max_jobs'] ?? 0), (int) ($config['max_seconds'] ?? 0));
            $processed = (int) ($loop['processed'] ?? 0);
            $elapsedMs = (int) ($loop['elapsed_ms'] ?? 0);
        }

        $this->sendLiveJsonSuccess([
            'profile' => (string) ($config['key'] ?? QueueLiveRunnerProfiles::DEFAULT_PROFILE),
            'processed' => $processed,
            'elapsed_ms' => $elapsedMs,
            'ran' => !empty($config['auto_run']),
            'status' => $this->buildLivePayload($sessionId),
        ]);
    }

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

        $sessionId = isset($_GET['session_id']) ? sanitize_key((string) wp_unslash($_GET['session_id'])) : '';
        if ($sessionId === '') {
            wp_die(esc_html__('Missing bulk session ID.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_bulk_session_export_' . $sessionId, 'wpsn_nonce');

        $format = isset($_GET['format']) ? sanitize_key((string) wp_unslash($_GET['format'])) : 'json';
        if (!in_array($format, ['json', 'csv'], true)) {
            $format = 'json';
        }

        $sessionRepo = new BulkQueueSessionRepository($this->container->get('logger'));
        $session = $sessionRepo->get($sessionId);
        if (empty($session)) {
            wp_die(esc_html__('Bulk session not found.', 'wp-seo-next'));
        }

        $reportRepo = new BulkSessionReportRepository();
        $export = $reportRepo->exportData($sessionId, $session);
        $filenameBase = 'wpsn-bulk-session-' . $sessionId;

        nocache_headers();

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

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

            fputcsv($fh, [
                'session_id', 'object_type', 'object_subtype', 'object_id', 'object_title', 'object_url',
                'generate_status', 'suggestion_source', 'suggested_title', 'suggested_description',
                'apply_status', 'changed_fields', 'before_title', 'before_description', 'after_title', 'after_description',
                'fix_status', 'fix_action', 'generate_note', 'apply_note', 'fix_note', 'updated_at'
            ]);

            $entries = isset($export['report']['entries']) && is_array($export['report']['entries']) ? $export['report']['entries'] : [];
            foreach ($entries as $entry) {
                if (!is_array($entry)) {
                    continue;
                }
                $changedFields = isset($entry['changed_fields']) && is_array($entry['changed_fields']) ? implode('|', $entry['changed_fields']) : '';
                fputcsv($fh, [
                    $sessionId,
                    (string) ($entry['object_type'] ?? ''),
                    (string) ($entry['object_subtype'] ?? ''),
                    (int) ($entry['object_id'] ?? 0),
                    (string) ($entry['object_title'] ?? ''),
                    (string) ($entry['object_url'] ?? ''),
                    (string) ($entry['generate_status'] ?? ''),
                    (string) ($entry['suggestion_source'] ?? ''),
                    (string) ($entry['suggested_title'] ?? ''),
                    (string) ($entry['suggested_description'] ?? ''),
                    (string) ($entry['apply_status'] ?? ''),
                    $changedFields,
                    (string) ($entry['before_title'] ?? ''),
                    (string) ($entry['before_description'] ?? ''),
                    (string) ($entry['after_title'] ?? ''),
                    (string) ($entry['after_description'] ?? ''),
                    (string) ($entry['fix_status'] ?? ''),
                    (string) ($entry['fix_action'] ?? ''),
                    (string) ($entry['generate_note'] ?? ''),
                    (string) ($entry['apply_note'] ?? ''),
                    (string) ($entry['fix_note'] ?? ''),
                    isset($entry['updated_at']) ? gmdate('c', (int) $entry['updated_at']) : '',
                ]);
            }

            fclose($fh);
            exit;
        }

        header('Content-Type: application/json; charset=utf-8');
        header('Content-Disposition: attachment; filename=' . $filenameBase . '.json');
        echo wp_json_encode($export, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        exit;
    }

    public function handleRun(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_run');

        if (QueuePause::isPaused()) {
            $this->redirect(['wpsn_notice' => 'queue_paused_blocked']);
        }

        // Default: 1 job. Hard-cap to keep the request short (avoid 503).
        $max = isset($_GET['max']) ? (int) $_GET['max'] : 1;
        $max = max(1, min(50, $max));

        $cap = defined('WPSN_MANUAL_WORKER_MAX_JOBS') ? (int) WPSN_MANUAL_WORKER_MAX_JOBS : 1;
        $cap = max(1, min(50, $cap));
        if ($max > $cap) {
            $max = $cap;
        }

        /** @var \WPSN\Core\Queue\QueueRunner $runner */
        $runner = $this->container->get('queue_runner');
        $processed = 0;
        try {
            $processed = (int) $runner->run($max);
        } catch (\Throwable $e) {
            // ignore
        }

        $this->redirect([
            'wpsn_notice' => 'queue_ran',
            'processed' => (string) $processed,
        ]);
    }

    public function handleRunBatch(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_run_batch');

        if (QueuePause::isPaused()) {
            $this->redirect(['wpsn_notice' => 'queue_paused_blocked']);
        }

        $requestMax = isset($_GET['max']) ? (int) $_GET['max'] : 10;
        $requestMax = max(1, min(25, $requestMax));

        $batchCap = defined('WPSN_MANUAL_BATCH_MAX_JOBS') ? (int) WPSN_MANUAL_BATCH_MAX_JOBS : 10;
        $batchCap = max(1, min(25, $batchCap));

        $maxSeconds = defined('WPSN_MANUAL_BATCH_MAX_SECONDS') ? (int) WPSN_MANUAL_BATCH_MAX_SECONDS : 12;
        $maxSeconds = max(3, min(20, $maxSeconds));

        $max = min($requestMax, $batchCap);

        $loop = $this->runWorkerLoop($max, $maxSeconds);

        $this->redirect([
            'wpsn_notice' => 'queue_batch_ran',
            'processed' => (string) ((int) ($loop['processed'] ?? 0)),
            'limit' => (string) $max,
            'elapsed_ms' => (string) ((int) ($loop['elapsed_ms'] ?? 0)),
        ]);
    }

    public function handlePause(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_pause');

        if ((defined('WPSN_QUEUE_PAUSE') && (bool) WPSN_QUEUE_PAUSE) || (defined('WPSN_QUEUE_PAUSE_UNTIL') && (int) WPSN_QUEUE_PAUSE_UNTIL > time())) {
            $this->redirect(['wpsn_notice' => 'queue_forced']);
        }

        $seconds = isset($_GET['for']) ? (int) $_GET['for'] : 3600;
        $seconds = max(60, min(7 * DAY_IN_SECONDS, $seconds));
        $until = QueuePause::pause($seconds, 'queue_center');

        $this->redirect([
            'wpsn_notice' => 'queue_paused',
            'until' => (string) $until,
        ]);
    }

    public function handleResume(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_resume');

        if ((defined('WPSN_QUEUE_PAUSE') && (bool) WPSN_QUEUE_PAUSE) || (defined('WPSN_QUEUE_PAUSE_UNTIL') && (int) WPSN_QUEUE_PAUSE_UNTIL > time())) {
            $this->redirect(['wpsn_notice' => 'queue_forced']);
        }

        QueuePause::resume();
        $this->redirect(['wpsn_notice' => 'queue_resumed']);
    }

    public function handleClearFailed(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_clear_failed');

        /** @var \WPSN\Core\Queue\QueueInterface $queue */
        $queue = $this->container->get('queue');
        $deleted = 0;
        try {
            $deleted = (int) $queue->clearFailed();
        } catch (\Throwable $e) {
            $deleted = 0;
        }

        $this->redirect([
            'wpsn_notice' => 'queue_failed_cleared',
            'n' => (string) $deleted,
        ]);
    }

    public function handleRequeueFailed(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_requeue_failed');

        /** @var \WPSN\Core\Queue\QueueInterface $queue */
        $queue = $this->container->get('queue');
        $n = 0;
        try {
            $n = (int) $queue->requeueFailed();
        } catch (\Throwable $e) {
            $n = 0;
        }

        $this->redirect([
            'wpsn_notice' => 'queue_failed_requeued',
            'n' => (string) $n,
        ]);
    }

    public function handleReleaseStale(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_release_stale');

        global $wpdb;
        $table = $wpdb->prefix . 'wpsn_queue_jobs';
        $threshold = isset($_GET['older_than']) ? (int) $_GET['older_than'] : 600;
        $threshold = max(300, min(12 * HOUR_IN_SECONDS, $threshold));
        $staleBefore = gmdate('Y-m-d H:i:s', time() - $threshold);
        $now = gmdate('Y-m-d H:i:s');

        $released = 0;
        try {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $released = (int) $wpdb->query($wpdb->prepare(
                "UPDATE {$table} SET status = %s, run_at = %s, locked_at = NULL, locked_by = NULL, last_error = NULL, updated_at = %s WHERE status = %s AND locked_at IS NOT NULL AND locked_at <> '' AND locked_at < %s",
                'pending',
                $now,
                $now,
                'running',
                $staleBefore
            ));
        } catch (\Throwable $e) {
            $released = 0;
        }

        $this->redirect([
            'wpsn_notice' => 'queue_stale_released',
            'n' => (string) max(0, $released),
        ]);
    }

    public function handlePostponeJob(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_postpone_job');

        $id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
        $delay = isset($_GET['delay']) ? (int) $_GET['delay'] : 900;
        $delay = max(60, min(30 * DAY_IN_SECONDS, $delay));

        if ($id > 0) {
            /** @var \WPSN\Core\Queue\QueueInterface $queue */
            $queue = $this->container->get('queue');
            try {
                $queue->retryLater($id, $delay, 'Postponed by admin');
            } catch (\Throwable $e) {
                // ignore
            }
        }

        $this->redirect(['wpsn_notice' => 'queue_job_postponed']);
    }

    public function handleForceRequeueJob(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_force_requeue');

        $id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
        if ($id > 0) {
            global $wpdb;
            $table = $wpdb->prefix . 'wpsn_queue_jobs';
            $now = gmdate('Y-m-d H:i:s');
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $wpdb->update(
                $table,
                [
                    'status' => 'pending',
                    'run_at' => $now,
                    'locked_at' => null,
                    'locked_by' => null,
                    'last_error' => null,
                    'attempts' => 0,
                    'updated_at' => $now,
                ],
                ['id' => $id],
                ['%s', '%s', '%s', '%s', '%s', '%d', '%s'],
                ['%d']
            );
        }

        $this->redirect(['wpsn_notice' => 'queue_job_requeued']);
    }

    public function handleDeleteJob(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_delete_job');

        $id = isset($_GET['id']) ? (int) $_GET['id'] : 0;
        if ($id > 0) {
            global $wpdb;
            $table = $wpdb->prefix . 'wpsn_queue_jobs';
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $wpdb->delete($table, ['id' => $id], ['%d']);
        }

        $this->redirect(['wpsn_notice' => 'queue_job_deleted']);
    }

    public function handleAutoPauseClear(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_autopause_clear');

        QueueAutoPause::clearState();

        $this->redirect(['wpsn_notice' => 'queue_autopause_cleared']);
    }

    public function handleRemoteUpdate(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_queue_remote_update');

        $enabled = !empty($_POST['enabled']);
        $maxJobs = isset($_POST['max_jobs']) ? (int) $_POST['max_jobs'] : 3;
        $rateLimit = isset($_POST['rate_limit']) ? (int) $_POST['rate_limit'] : 120;
        $rateWindow = isset($_POST['rate_window']) ? (int) $_POST['rate_window'] : 3600;
        $regen = !empty($_POST['regenerate']);

        $maxJobs = max(1, min(20, $maxJobs));
        $rateLimit = max(10, min(5000, $rateLimit));
        $rateWindow = max(60, min(7 * DAY_IN_SECONDS, $rateWindow));

        if ($enabled) {
            QueueRemoteRunnerConfig::enable($maxJobs, $rateLimit, $rateWindow, $regen);
            $this->redirect(['wpsn_notice' => 'queue_remote_saved']);
        }

        QueueRemoteRunnerConfig::disable();
        $this->redirect(['wpsn_notice' => 'queue_remote_disabled']);
    }

    private function notice(): void
    {
        $n = isset($_GET['wpsn_notice']) ? sanitize_key((string) wp_unslash($_GET['wpsn_notice'])) : '';
        if ($n === '') {
            return;
        }

        $msg = '';
        if ($n === 'queue_ran') {
            $p = isset($_GET['processed']) ? (int) $_GET['processed'] : 0;
            $msg = sprintf(__('Worker ran: %d job(s) processed.', 'wp-seo-next'), $p);
        } elseif ($n === 'queue_batch_ran') {
            $p = isset($_GET['processed']) ? (int) $_GET['processed'] : 0;
            $limit = isset($_GET['limit']) ? (int) $_GET['limit'] : 0;
            $elapsedMs = isset($_GET['elapsed_ms']) ? (int) $_GET['elapsed_ms'] : 0;
            if ($limit > 0) {
                $msg = sprintf(__('Safe batch ran: %1$d of %2$d job(s) processed in %3$d ms.', 'wp-seo-next'), $p, $limit, max(0, $elapsedMs));
            } else {
                $msg = sprintf(__('Safe batch ran: %d job(s) processed.', 'wp-seo-next'), $p);
            }
        } elseif ($n === 'queue_paused_blocked') {
            $msg = __('Queue is paused — worker run skipped.', 'wp-seo-next');
        } elseif ($n === 'queue_paused') {
            $until = isset($_GET['until']) ? (int) $_GET['until'] : 0;
            $msg = $until > 0 ? sprintf(__('Queue paused until (UTC): %s', 'wp-seo-next'), gmdate('Y-m-d H:i:s', $until)) : __('Queue paused.', 'wp-seo-next');
        } elseif ($n === 'queue_resumed') {
            $msg = __('Queue resumed.', 'wp-seo-next');
        } elseif ($n === 'queue_forced') {
            $msg = __('Queue pause is forced by wp-config (WPSN_QUEUE_PAUSE / WPSN_QUEUE_PAUSE_UNTIL).', 'wp-seo-next');
        } elseif ($n === 'queue_failed_cleared') {
            $c = isset($_GET['n']) ? (int) $_GET['n'] : 0;
            $msg = sprintf(__('Cleared failed jobs: %d', 'wp-seo-next'), $c);
        } elseif ($n === 'queue_failed_requeued') {
            $c = isset($_GET['n']) ? (int) $_GET['n'] : 0;
            $msg = sprintf(__('Requeued failed jobs: %d', 'wp-seo-next'), $c);
        } elseif ($n === 'queue_stale_released') {
            $c = isset($_GET['n']) ? (int) $_GET['n'] : 0;
            $msg = sprintf(__('Released stale running jobs back to pending: %d', 'wp-seo-next'), $c);
        } elseif ($n === 'queue_job_postponed') {
            $msg = __('Job postponed.', 'wp-seo-next');
        } elseif ($n === 'queue_job_requeued') {
            $msg = __('Job requeued.', 'wp-seo-next');
        } elseif ($n === 'queue_job_deleted') {
            $msg = __('Job deleted.', 'wp-seo-next');
        } elseif ($n === 'queue_autopause_cleared') {
            $msg = __('Auto-pause history cleared.', 'wp-seo-next');
        } elseif ($n === 'queue_remote_saved') {
            $msg = __('Remote runner settings saved.', 'wp-seo-next');
        } elseif ($n === 'queue_remote_disabled') {
            $msg = __('Remote runner disabled.', 'wp-seo-next');
        }

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

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

    /**
     * @param string $sql
     * @param array<int,mixed> $args
     */
    private function maybePrepare(string $sql, array $args): string
    {
        global $wpdb;
        if (empty($args)) {
            return $sql;
        }
        // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
        return (string) $wpdb->prepare($sql, $args);
    }

    /**
     * @return array{ready_pending:int,stale_running:int,oldest_pending:string,oldest_running_lock:string,next_tick:int,mode:string}
     */
    private function runtimeMetrics(string $table, string $queueMode): array
    {
        global $wpdb;

        $now = gmdate('Y-m-d H:i:s');
        $staleBefore = gmdate('Y-m-d H:i:s', time() - 600);

        $readyPending = 0;
        $staleRunning = 0;
        $oldestPending = '';
        $oldestRunningLock = '';

        try {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $readyPending = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$table} WHERE status = %s AND run_at <= %s",
                'pending',
                $now
            ));
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $staleRunning = (int) $wpdb->get_var($wpdb->prepare(
                "SELECT COUNT(*) FROM {$table} WHERE status = %s AND locked_at IS NOT NULL AND locked_at <> '' AND locked_at < %s",
                'running',
                $staleBefore
            ));
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $oldestPending = (string) $wpdb->get_var($wpdb->prepare(
                "SELECT run_at FROM {$table} WHERE status = %s ORDER BY run_at ASC, id ASC LIMIT 1",
                'pending'
            ));
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $oldestRunningLock = (string) $wpdb->get_var($wpdb->prepare(
                "SELECT locked_at FROM {$table} WHERE status = %s AND locked_at IS NOT NULL AND locked_at <> '' ORDER BY locked_at ASC, id ASC LIMIT 1",
                'running'
            ));
        } catch (\Throwable $e) {
            $readyPending = 0;
            $staleRunning = 0;
            $oldestPending = '';
            $oldestRunningLock = '';
        }

        return [
            'ready_pending' => max(0, $readyPending),
            'stale_running' => max(0, $staleRunning),
            'oldest_pending' => $oldestPending,
            'oldest_running_lock' => $oldestRunningLock,
            'next_tick' => (int) wp_next_scheduled('wpsn_queue_tick'),
            'mode' => sanitize_key($queueMode),
        ];
    }

    /**
     * @return array<int,array{family:string,total:int,pending:int,running:int,failed:int,done:int,examples:array<int,string>}>
     */
    private function jobFamilySummary(string $table): array
    {
        global $wpdb;

        try {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
            $rows = $wpdb->get_results("SELECT job_type, status, COUNT(*) AS c FROM {$table} GROUP BY job_type, status ORDER BY job_type ASC", ARRAY_A);
        } catch (\Throwable $e) {
            return [];
        }

        if (!is_array($rows) || empty($rows)) {
            return [];
        }

        $families = [];
        foreach ($rows as $row) {
            if (!is_array($row)) {
                continue;
            }
            $jobType = isset($row['job_type']) ? sanitize_key((string) $row['job_type']) : '';
            $status = isset($row['status']) ? sanitize_key((string) $row['status']) : '';
            $count = isset($row['c']) ? (int) $row['c'] : 0;
            if ($jobType === '' || $count <= 0) {
                continue;
            }

            $family = $this->jobFamilyForType($jobType);
            if (!isset($families[$family])) {
                $families[$family] = [
                    'family' => $family,
                    'total' => 0,
                    'pending' => 0,
                    'running' => 0,
                    'failed' => 0,
                    'done' => 0,
                    'examples' => [],
                ];
            }

            $families[$family]['total'] += $count;
            if (isset($families[$family][$status])) {
                $families[$family][$status] += $count;
            }
            if (count($families[$family]['examples']) < 4 && !in_array($jobType, $families[$family]['examples'], true)) {
                $families[$family]['examples'][] = $jobType;
            }
        }

        usort($families, static function (array $a, array $b): int {
            if ($a['pending'] === $b['pending']) {
                return $b['total'] <=> $a['total'];
            }
            return $b['pending'] <=> $a['pending'];
        });

        return array_values($families);
    }

    private function jobFamilyForType(string $jobType): string
    {
        $jobType = sanitize_key($jobType);
        if ($jobType === '') {
            return 'other';
        }

        $known = [
            'audit' => 'audit',
            'bulk' => 'bulk',
            'media' => 'media',
            'site' => 'site',
            'gsc' => 'gsc',
            'search' => 'search',
            'schema' => 'schema',
            'notifications' => 'notifications',
            'autopilot' => 'autopilot',
            'sitemap' => 'sitemap',
        ];

        $prefix = strpos($jobType, '_') !== false ? (string) strstr($jobType, '_', true) : $jobType;
        if (isset($known[$prefix])) {
            return $known[$prefix];
        }

        if (strpos($jobType, 'site_crawl') === 0) {
            return 'site-crawl';
        }
        if (strpos($jobType, 'search_console') === 0) {
            return 'search-console';
        }
        if (strpos($jobType, 'sitekit') === 0) {
            return 'site-kit';
        }

        return $prefix !== '' ? $prefix : 'other';
    }

    private function bulkSessionExportUrl(string $sessionId, string $format): string
    {
        $sessionId = sanitize_key($sessionId);
        $format = sanitize_key($format);
        if (!in_array($format, ['json', 'csv'], true)) {
            $format = 'json';
        }

        return wp_nonce_url(
            admin_url('admin-post.php?action=wpsn_bulk_session_export&session_id=' . rawurlencode($sessionId) . '&format=' . rawurlencode($format)),
            'wpsn_bulk_session_export_' . $sessionId,
            'wpsn_nonce'
        );
    }

    /**
     * @param array<int,array<string,mixed>> $sessions
     */
    private function renderBulkSessionsCard(array $sessions, string $activeSessionId): void
    {
        if (empty($sessions)) {
            return;
        }

        $repo = new BulkQueueSessionRepository($this->container->get('logger'));

        echo '<div class="wpsn-card" style="max-width:1200px; margin-top:12px;">';
        echo '<h2 style="margin-top:0;">' . esc_html__('Bulk meta fix sessions', 'wp-seo-next') . '</h2>';
        echo '<p class="description" style="margin-top:-6px;">' . esc_html__('Recent bulk meta fix runs. Open one session to see only its jobs and live progress.', 'wp-seo-next') . '</p>';
        echo '<table class="widefat striped" style="margin-top:10px;">';
        echo '<thead><tr>';
        echo '<th>' . esc_html__('Session', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Action', 'wp-seo-next') . '</th>';
        echo '<th style="width:120px;">' . esc_html__('Targets', 'wp-seo-next') . '</th>';
        echo '<th style="width:100px;">' . esc_html__('Jobs', 'wp-seo-next') . '</th>';
        echo '<th style="width:240px;">' . esc_html__('Progress', 'wp-seo-next') . '</th>';
        echo '<th style="width:170px;">' . esc_html__('Updated', 'wp-seo-next') . '</th>';
        echo '<th style="width:150px;">' . esc_html__('Actions', 'wp-seo-next') . '</th>';
        echo '</tr></thead><tbody>';

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

            $progress = $repo->progress($sessionId);
            $percent = isset($progress['percent']) ? (int) $progress['percent'] : 0;
            $queuedJobs = isset($progress['queued_jobs']) ? (int) $progress['queued_jobs'] : (int) ($session['queued_jobs'] ?? 0);
            $terminal = isset($progress['terminal']) ? (int) $progress['terminal'] : 0;
            $updatedAt = isset($session['updated_at']) ? (int) $session['updated_at'] : 0;
            $sessionUrl = add_query_arg([
                'page' => self::PAGE_SLUG,
                'bulk_session' => $sessionId,
                'status' => 'all',
            ], admin_url('tools.php'));
            $clearUrl = add_query_arg([
                'page' => self::PAGE_SLUG,
                'status' => 'all',
            ], admin_url('tools.php'));

            echo '<tr>';
            echo '<td><code>' . esc_html($sessionId) . '</code>';
            if ($sessionId === $activeSessionId) {
                echo ' <span style="display:inline-block; padding:2px 6px; border-radius:999px; background:#e7f5ff; color:#0a4b78; font-size:11px; font-weight:600;">' . esc_html__('Filtered', 'wp-seo-next') . '</span>';
            }
            echo '</td>';
            echo '<td>' . esc_html($repo->actionLabel((string) ($session['action'] ?? ''))) . '</td>';
            echo '<td>' . esc_html(sprintf(__('%1$d found / %2$d eligible', 'wp-seo-next'), (int) ($session['targets'] ?? 0), (int) ($session['eligible_targets'] ?? 0))) . '</td>';
            echo '<td>' . esc_html((string) $queuedJobs) . '</td>';
            echo '<td>';
            echo '<div style="height:10px; background:#f0f0f1; border-radius:999px; overflow:hidden; margin-bottom:6px;"><div style="height:10px; width:' . esc_attr((string) max(0, min(100, $percent))) . '%; background:#2271b1;"></div></div>';
            echo '<span class="description">' . esc_html(sprintf(__('%1$d%% · %2$d/%3$d done · failed %4$d', 'wp-seo-next'), $percent, $terminal, max(0, $queuedJobs), (int) ($progress['failed'] ?? 0))) . '</span>';
            echo '</td>';
            echo '<td>' . esc_html($updatedAt > 0 ? gmdate('Y-m-d H:i:s', $updatedAt) . ' UTC' : '—') . '</td>';
            echo '<td><a class="button button-secondary" href="' . esc_url($sessionUrl) . '">' . esc_html__('View jobs', 'wp-seo-next') . '</a>';
            echo ' <a class="button button-link" href="' . esc_url($this->bulkSessionExportUrl($sessionId, 'json')) . '">' . esc_html__('JSON', 'wp-seo-next') . '</a>';
            if ($sessionId === $activeSessionId) {
                echo ' <a class="button" href="' . esc_url($clearUrl) . '">' . esc_html__('Clear filter', 'wp-seo-next') . '</a>';
            }
            echo '</td>';
            echo '</tr>';
        }

        echo '</tbody></table>';
        echo '</div>';
    }

    /**
     * @param array<string,mixed> $session
     * @param array<string,mixed> $progress
     */
    private function renderActiveBulkSessionCard(array $session, array $progress): void
    {
        if (empty($session) || empty($progress)) {
            return;
        }

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

        $repo = new BulkQueueSessionRepository($this->container->get('logger'));
        $queueUrl = add_query_arg([
            'page' => self::PAGE_SLUG,
            'status' => 'all',
        ], admin_url('tools.php'));
        $returnUrl = isset($session['return_url']) ? esc_url_raw((string) $session['return_url']) : '';
        $percent = isset($progress['percent']) ? (int) $progress['percent'] : 0;
        $queuedJobs = isset($progress['queued_jobs']) ? (int) $progress['queued_jobs'] : 0;
        $terminal = isset($progress['terminal']) ? (int) $progress['terminal'] : 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));

        echo '<div class="wpsn-card 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="max-width:1200px; margin-top:12px; border-left:4px solid #2271b1;">';
        echo '<div style="display:flex; justify-content:space-between; gap:12px; flex-wrap:wrap; align-items:flex-start;">';
        echo '<div>';
        echo '<h2 style="margin:0 0 6px 0;">' . esc_html__('Filtered bulk session', 'wp-seo-next') . '</h2>';
        echo '<p style="margin:0;"><code>' . esc_html($sessionId) . '</code> — ' . esc_html($repo->actionLabel((string) ($session['action'] ?? ''))) . '</p>';
        echo '<p class="description" style="margin:6px 0 0 0;">' . esc_html(sprintf(__('Targets: %1$d found · %2$d eligible · queued jobs: %3$d', 'wp-seo-next'), (int) ($session['targets'] ?? 0), (int) ($session['eligible_targets'] ?? 0), $queuedJobs)) . '</p>';
        echo '</div>';
        echo '<div>';
        echo '<a class="button button-secondary" href="' . esc_url($queueUrl) . '">' . esc_html__('Show all queue jobs', 'wp-seo-next') . '</a>';
        echo ' <a class="button" href="' . esc_url($this->bulkSessionExportUrl($sessionId, 'json')) . '">' . esc_html__('Download JSON report', 'wp-seo-next') . '</a>';
        echo ' <a class="button" href="' . esc_url($this->bulkSessionExportUrl($sessionId, 'csv')) . '">' . esc_html__('Download CSV', '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;"><div class="wpsn-queue-live-progress-bar" style="height:10px; width:' . esc_attr((string) max(0, min(100, $percent))) . '%; background:#2271b1;"></div></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 · pending %3$d · running %4$d · failed %5$d', 'wp-seo-next'), $terminal, $queuedJobs, (int) ($progress['pending'] ?? 0), (int) ($progress['running'] ?? 0), (int) ($progress['failed'] ?? 0))) . '</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>';
        }
        if (!empty($progress['last_error'])) {
            echo '<details style="margin-top:8px;"><summary>' . esc_html__('Latest job error in this session', 'wp-seo-next') . '</summary><pre style="white-space:pre-wrap; max-width:100%; overflow:auto; margin-top:8px;">' . esc_html((string) $progress['last_error']) . '</pre></details>';
        }
        echo '</div>';
    }

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

        $reportRepo = new BulkSessionReportRepository();
        $export = $reportRepo->exportData($sessionId, $session);
        $summary = isset($export['report']['summary']) && is_array($export['report']['summary']) ? $export['report']['summary'] : [];
        $entries = isset($export['report']['entries']) && is_array($export['report']['entries']) ? $export['report']['entries'] : [];
        if (empty($summary) && empty($entries)) {
            return;
        }

        $entries = array_slice($entries, 0, 100);

        echo '<div id="wpsn-bulk-session-report" class="wpsn-card" style="max-width:1200px; margin-top:12px;">';
        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__('Bulk session change report', 'wp-seo-next') . '</h2>';
        echo '<p class="description" style="margin:0;">' . esc_html__('This report keeps the generated suggestions and what was finally applied, so the results do not “disappear” after the issue list refreshes.', 'wp-seo-next') . '</p>';
        echo '</div>';
        echo '<div>';
        echo '<a class="button" href="' . esc_url($this->bulkSessionExportUrl($sessionId, 'json')) . '">' . esc_html__('Download JSON report', 'wp-seo-next') . '</a>';
        echo ' <a class="button" href="' . esc_url($this->bulkSessionExportUrl($sessionId, 'csv')) . '">' . esc_html__('Download CSV', 'wp-seo-next') . '</a>';
        echo '</div>';
        echo '</div>';

        echo '<p style="margin:10px 0 0 0;">';
        echo esc_html(sprintf(
            __('Objects: %1$d · generated: %2$d · meta updated: %3$d · no change: %4$d · skipped apply: %5$d', 'wp-seo-next'),
            (int) ($summary['objects'] ?? 0),
            (int) ($summary['generated'] ?? 0),
            (int) ($summary['applied'] ?? 0),
            (int) ($summary['apply_no_change'] ?? 0),
            (int) ($summary['apply_skipped'] ?? 0)
        ));
        echo '</p>';

        if (empty($entries)) {
            echo '<p class="description" style="margin-top:8px;">' . esc_html__('No object-level report entries yet. Run a worker batch first, then refresh this page.', 'wp-seo-next') . '</p>';
            echo '</div>';
            return;
        }

        echo '<table class="widefat striped" style="margin-top:12px;">';
        echo '<thead><tr>';
        echo '<th style="width:270px;">' . esc_html__('Object', 'wp-seo-next') . '</th>';
        echo '<th style="width:120px;">' . esc_html__('Generate', 'wp-seo-next') . '</th>';
        echo '<th style="width:120px;">' . esc_html__('Apply', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Suggested', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Before → after', 'wp-seo-next') . '</th>';
        echo '</tr></thead><tbody>';

        foreach ($entries as $entry) {
            if (!is_array($entry)) {
                continue;
            }
            $objectTitle = trim((string) ($entry['object_title'] ?? ''));
            $objectTitle = $objectTitle !== '' ? $objectTitle : ('#' . (int) ($entry['object_id'] ?? 0));
            $objectUrl = (string) ($entry['object_url'] ?? '');
            $objectMeta = trim(implode(' · ', array_filter([
                (string) ($entry['object_type'] ?? ''),
                (string) ($entry['object_subtype'] ?? ''),
                '#' . (int) ($entry['object_id'] ?? 0),
            ])));
            $suggestedTitle = trim((string) ($entry['suggested_title'] ?? ''));
            $suggestedDescription = trim((string) ($entry['suggested_description'] ?? ''));
            $beforeTitle = trim((string) ($entry['before_title'] ?? ''));
            $beforeDescription = trim((string) ($entry['before_description'] ?? ''));
            $afterTitle = trim((string) ($entry['after_title'] ?? ''));
            $afterDescription = trim((string) ($entry['after_description'] ?? ''));
            $generateStatus = (string) ($entry['generate_status'] ?? '');
            $applyStatus = (string) ($entry['apply_status'] ?? '');
            $generateNote = trim((string) ($entry['generate_note'] ?? ''));
            $applyNote = trim((string) ($entry['apply_note'] ?? ''));
            $changedFields = isset($entry['changed_fields']) && is_array($entry['changed_fields']) ? implode(', ', array_map('strval', $entry['changed_fields'])) : '';

            echo '<tr>';
            echo '<td><strong>';
            if ($objectUrl !== '') {
                echo '<a href="' . esc_url($objectUrl) . '" target="_blank" rel="noopener noreferrer">' . esc_html($objectTitle) . '</a>';
            } else {
                echo esc_html($objectTitle);
            }
            echo '</strong><br/><span class="description">' . esc_html($objectMeta) . '</span></td>';

            echo '<td><strong>' . esc_html($generateStatus !== '' ? $generateStatus : '—') . '</strong>';
            if ($generateNote !== '') {
                echo '<br/><span class="description">' . esc_html($generateNote) . '</span>';
            }
            echo '</td>';

            echo '<td><strong>' . esc_html($applyStatus !== '' ? $applyStatus : '—') . '</strong>';
            if ($changedFields !== '') {
                echo '<br/><span class="description">' . esc_html(sprintf(__('Changed: %s', 'wp-seo-next'), $changedFields)) . '</span>';
            }
            if ($applyNote !== '') {
                echo '<br/><span class="description">' . esc_html($applyNote) . '</span>';
            }
            echo '</td>';

            echo '<td>';
            if ($suggestedTitle !== '') {
                echo '<div><strong>' . esc_html__('Title', 'wp-seo-next') . ':</strong> ' . esc_html($suggestedTitle) . '</div>';
            }
            if ($suggestedDescription !== '') {
                echo '<div style="margin-top:4px;"><strong>' . esc_html__('Description', 'wp-seo-next') . ':</strong> ' . esc_html($suggestedDescription) . '</div>';
            }
            if ($suggestedTitle === '' && $suggestedDescription === '') {
                echo '—';
            }
            echo '</td>';

            echo '<td>';
            if ($beforeTitle !== '' || $afterTitle !== '') {
                echo '<div><strong>' . esc_html__('Title', 'wp-seo-next') . ':</strong> ' . esc_html($beforeTitle !== '' ? $beforeTitle : '—') . ' → ' . esc_html($afterTitle !== '' ? $afterTitle : '—') . '</div>';
            }
            if ($beforeDescription !== '' || $afterDescription !== '') {
                echo '<div style="margin-top:4px;"><strong>' . esc_html__('Description', 'wp-seo-next') . ':</strong> ' . esc_html($beforeDescription !== '' ? $beforeDescription : '—') . ' → ' . esc_html($afterDescription !== '' ? $afterDescription : '—') . '</div>';
            }
            if ($beforeTitle === '' && $afterTitle === '' && $beforeDescription === '' && $afterDescription === '') {
                echo '—';
            }
            echo '</td>';
            echo '</tr>';
        }

        echo '</tbody></table>';
        echo '<p class="description" style="margin:8px 0 0 0;">' . esc_html__('The table shows the latest 100 objects in this session. Download the JSON or CSV report for the full raw file you can upload here later.', 'wp-seo-next') . '</p>';
        echo '</div>';
    }

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

        // Ensure queue schema exists.
        \WPSN\Core\Queue\QueueInstaller::maybeInstall();

        /** @var \WPSN\Core\Queue\QueueInterface $queue */
        $queue = $this->container->get('queue');
        $counts = [];
        try {
            $counts = $queue->counts();
        } catch (\Throwable $e) {
            $counts = ['pending' => 0, 'running' => 0, 'done' => 0, 'failed' => 0];
        }

        $paused = QueuePause::isPaused();
        $pauseUntil = QueuePause::pauseUntil();
        $pauseReason = QueuePause::reason();

        $pauseForced = (defined('WPSN_QUEUE_PAUSE') && (bool) WPSN_QUEUE_PAUSE) || (defined('WPSN_QUEUE_PAUSE_UNTIL') && (int) WPSN_QUEUE_PAUSE_UNTIL > time());

        $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : 'pending';
        $allowedStatus = ['all', 'pending', 'running', 'failed', 'done'];
        if (!in_array($status, $allowedStatus, true)) {
            $status = 'pending';
        }

        $typeFilterRaw = isset($_GET['job_type']) ? sanitize_text_field((string) wp_unslash($_GET['job_type'])) : '';
        $typeFilterRaw = trim($typeFilterRaw);

        $bulkSessionId = isset($_GET['bulk_session']) ? sanitize_key((string) wp_unslash($_GET['bulk_session'])) : '';
        $sessionRepo = new BulkQueueSessionRepository($this->container->get('logger'));
        $recentBulkSessions = $sessionRepo->listRecent(8);
        $activeBulkSession = $bulkSessionId !== '' ? $sessionRepo->get($bulkSessionId) : [];
        $activeBulkProgress = $bulkSessionId !== '' ? $sessionRepo->progress($bulkSessionId) : [];
        $sessionJobIds = $bulkSessionId !== '' ? $sessionRepo->jobIds($bulkSessionId) : [];

        $paged = isset($_GET['paged']) ? (int) $_GET['paged'] : 1;
        $paged = max(1, $paged);
        $perPage = 50;
        $offset = ($paged - 1) * $perPage;

        global $wpdb;
        $table = $wpdb->prefix . 'wpsn_queue_jobs';

        $where = '1=1';
        $args = [];
        if ($status !== 'all') {
            $where .= ' AND status = %s';
            $args[] = $status;
        }
        if ($typeFilterRaw !== '') {
            $where .= ' AND job_type LIKE %s';
            $args[] = '%' . $wpdb->esc_like($typeFilterRaw) . '%';
        }
        if ($bulkSessionId !== '') {
            if (empty($sessionJobIds)) {
                $where .= ' AND 1 = 0';
            } else {
                $placeholders = implode(', ', array_fill(0, count($sessionJobIds), '%d'));
                $where .= " AND id IN ({$placeholders})";
                $args = array_merge($args, $sessionJobIds);
            }
        }

        // Total count.
        $countSql = "SELECT COUNT(*) FROM {$table} WHERE {$where}";
        $countSqlPrepared = $this->maybePrepare($countSql, $args);
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $total = (int) $wpdb->get_var($countSqlPrepared);
        $totalPages = (int) max(1, (int) ceil($total / $perPage));

        // Rows.
        $rowsSql = "SELECT * FROM {$table} WHERE {$where} ORDER BY run_at ASC, id ASC LIMIT %d OFFSET %d";
        $rowsArgs = array_merge($args, [$perPage, $offset]);
        $rowsSqlPrepared = $this->maybePrepare($rowsSql, $rowsArgs);
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery
        $rows = $wpdb->get_results($rowsSqlPrepared, ARRAY_A);
        if (!is_array($rows)) {
            $rows = [];
        }

        $manualCap = defined('WPSN_MANUAL_WORKER_MAX_JOBS') ? (int) WPSN_MANUAL_WORKER_MAX_JOBS : 1;
        $manualCap = max(1, min(50, $manualCap));

        /** @var \WPSN\Core\Settings $settings */
        $settings = $this->container->get('settings');
        $queueMode = sanitize_key((string) $settings->get('performance.queue.mode', 'auto'));
        $apEnabled = (bool) $settings->get('performance.queue.autopause.enabled', true);
        $apThreshold = (int) $settings->get('performance.queue.autopause.threshold', 3);
        $apWindow = (int) $settings->get('performance.queue.autopause.window_minutes', 10);
        $apPause = (int) $settings->get('performance.queue.autopause.pause_minutes', 30);
        $apState = QueueAutoPause::getState();
        $apBurst = isset($apState['burst_count']) ? (int) $apState['burst_count'] : 0;
        $apLastAt = isset($apState['last_seen_at']) ? (int) $apState['last_seen_at'] : 0;
        $apLastStatus = isset($apState['last_status']) ? (int) $apState['last_status'] : 0;
        $apPauseUntil = isset($apState['pause_until']) ? (int) $apState['pause_until'] : 0;
        $apLastErr = isset($apState['last_error']) ? (string) $apState['last_error'] : '';

        // Remote runner (external cron URL) config.
        $remoteEnabled = QueueRemoteRunnerConfig::enabled();
        $remoteToken = $remoteEnabled ? QueueRemoteRunnerConfig::ensureToken() : QueueRemoteRunnerConfig::token();
        $remoteMaxJobs = QueueRemoteRunnerConfig::maxJobs();
        $remoteRateLimit = QueueRemoteRunnerConfig::rateLimit();
        $remoteRateWindow = QueueRemoteRunnerConfig::rateWindowSeconds();

        $remoteBase = function_exists('rest_url') ? rest_url('wpsn/v1/queue/tick') : '';
        $remoteUrl = '';
        if ($remoteBase !== '' && $remoteToken !== '') {
            $remoteUrl = add_query_arg([
                'token' => $remoteToken,
                'max' => (string) $remoteMaxJobs,
            ], $remoteBase);
        }

        $viewArgs = $this->currentViewArgs();
        $run1 = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_run&max=1')), 'wpsn_queue_run');
        $run5 = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_run&max=5')), 'wpsn_queue_run');
        $runBatch = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_run_batch&max=10')), 'wpsn_queue_run_batch');
        $pause15 = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_pause&for=900')), 'wpsn_queue_pause');
        $pause60 = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_pause&for=3600')), 'wpsn_queue_pause');
        $pause6h = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_pause&for=' . (6 * HOUR_IN_SECONDS))), 'wpsn_queue_pause');
        $resume = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_resume')), 'wpsn_queue_resume');
        $clearFailed = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_clear_failed')), 'wpsn_queue_clear_failed');
        $requeueFailed = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_requeue_failed')), 'wpsn_queue_requeue_failed');
        $releaseStale = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_release_stale&older_than=600')), 'wpsn_queue_release_stale');
        $clearAutoPause = wp_nonce_url(add_query_arg($viewArgs, admin_url('admin-post.php?action=wpsn_queue_autopause_clear')), 'wpsn_queue_autopause_clear');
        $metrics = $this->runtimeMetrics($table, $queueMode);
        $familySummary = $this->jobFamilySummary($table);

        echo '<div class="wrap">';
        echo '<h1>' . esc_html__('Queue Center', 'wp-seo-next') . '</h1>';

        // Minimal styles (keep it self-contained; do not rely on global CSS).
        echo '<style>
            .wpsn-card{background:#fff;border:1px solid #dcdcde;border-radius:6px;padding:12px;}
        </style>';

        $this->notice();

        if (!empty($recentBulkSessions)) {
            $this->renderBulkSessionsCard($recentBulkSessions, $bulkSessionId);
        }
        if ($bulkSessionId !== '' && !empty($activeBulkSession) && !empty($activeBulkProgress)) {
            $this->renderActiveBulkSessionCard($activeBulkSession, $activeBulkProgress);
            $this->renderActiveBulkSessionReport($activeBulkSession);
        }

        echo '<div class="wpsn-card" style="max-width:1200px; margin-top:12px;">';
        echo '<p class="description" style="margin-top:0;">' . esc_html__('Operational controls for WP SEO Next background queue. Use this to avoid 503 on shared hosting and to inspect stuck jobs.', 'wp-seo-next') . '</p>';

        // Status.
        echo '<p style="margin:10px 0 0 0;">';
        echo '<strong>' . esc_html__('Counts:', 'wp-seo-next') . '</strong> ';
        echo esc_html__('Pending:', 'wp-seo-next') . ' <span data-wpsn-queue-count="pending">' . esc_html((string) ((int) ($counts['pending'] ?? 0))) . '</span> · ';
        echo esc_html__('Running:', 'wp-seo-next') . ' <span data-wpsn-queue-count="running">' . esc_html((string) ((int) ($counts['running'] ?? 0))) . '</span> · ';
        echo esc_html__('Failed:', 'wp-seo-next') . ' <span data-wpsn-queue-count="failed">' . esc_html((string) ((int) ($counts['failed'] ?? 0))) . '</span> · ';
        echo esc_html__('Done:', 'wp-seo-next') . ' <span data-wpsn-queue-count="done">' . esc_html((string) ((int) ($counts['done'] ?? 0))) . '</span>';
        echo '</p>';

        echo '<div style="display:flex; gap:12px; flex-wrap:wrap; margin-top:12px;">';
        echo '<div style="flex:1 1 260px; min-width:260px; background:#f6f7f7; border:1px solid #dcdcde; border-radius:6px; padding:10px;">';
        echo '<strong>' . esc_html__('Worker mode', 'wp-seo-next') . ':</strong> <code>' . esc_html($queueMode !== '' ? $queueMode : 'auto') . '</code><br/>';
        if ((int) $metrics['next_tick'] > 0) {
            echo '<span class="description">' . esc_html(sprintf(__('Next WP-Cron tick: %s UTC', 'wp-seo-next'), gmdate('Y-m-d H:i:s', (int) $metrics['next_tick']))) . '</span>';
        } else {
            echo '<span class="description">' . esc_html__('Next WP-Cron tick: not scheduled', 'wp-seo-next') . '</span>';
        }
        echo '</div>';

        echo '<div style="flex:1 1 260px; min-width:260px; background:#f6f7f7; border:1px solid #dcdcde; border-radius:6px; padding:10px;">';
        echo '<strong>' . esc_html__('Ready now', 'wp-seo-next') . ':</strong> ' . esc_html((string) ((int) $metrics['ready_pending'])) . '<br/>';
        $oldestPending = isset($metrics['oldest_pending']) ? (string) $metrics['oldest_pending'] : '';
        if ($oldestPending !== '') {
            echo '<span class="description">' . esc_html(sprintf(__('Oldest pending run_at: %s UTC', 'wp-seo-next'), $oldestPending)) . '</span>';
        } else {
            echo '<span class="description">' . esc_html__('No pending jobs waiting right now.', 'wp-seo-next') . '</span>';
        }
        echo '</div>';

        echo '<div style="flex:1 1 260px; min-width:260px; background:#f6f7f7; border:1px solid #dcdcde; border-radius:6px; padding:10px;">';
        echo '<strong>' . esc_html__('Possibly stuck', 'wp-seo-next') . ':</strong> ' . esc_html((string) ((int) $metrics['stale_running'])) . '<br/>';
        $oldestRunning = isset($metrics['oldest_running_lock']) ? (string) $metrics['oldest_running_lock'] : '';
        if ($oldestRunning !== '') {
            echo '<span class="description">' . esc_html(sprintf(__('Oldest running lock: %s UTC', 'wp-seo-next'), $oldestRunning)) . '</span>';
        } else {
            echo '<span class="description">' . esc_html__('No long-running locked jobs detected.', 'wp-seo-next') . '</span>';
        }
        echo '</div>';
        echo '</div>';

        if ($queueMode === 'manual' && (int) $metrics['ready_pending'] > 0) {
            echo '<div class="notice notice-warning inline" style="margin:12px 0 0 0;"><p>' . esc_html__('Queue mode is MANUAL and jobs are ready now. Nothing will move until you run a worker batch, use WP-CLI, or ping the remote runner URL.', 'wp-seo-next') . '</p></div>';
        } elseif ($queueMode === 'auto' && (int) $metrics['ready_pending'] > 0 && (int) $metrics['next_tick'] === 0) {
            echo '<div class="notice notice-warning inline" style="margin:12px 0 0 0;"><p>' . esc_html__('Jobs are ready but no WP-Cron queue tick is scheduled. Open Diagnostics → Settings or use the remote runner if your host blocks WP-Cron.', 'wp-seo-next') . '</p></div>';
        }

        // Auto-pause status.
        echo '<p style="margin:6px 0 0 0;">';
        if ($apEnabled) {
            echo '<strong>' . esc_html__('Auto-pause:', 'wp-seo-next') . '</strong> ';
            echo '<span class="description">' . esc_html(sprintf(__('Enabled · %d events / %d min → pause %d min', 'wp-seo-next'), $apThreshold, $apWindow, $apPause)) . '</span>';
            if ($apLastAt > 0) {
                echo '<br/><span class="description">' . esc_html(sprintf(__('Last signal: %s (HTTP %d) · Burst count: %d', 'wp-seo-next'), gmdate('Y-m-d H:i:s', $apLastAt) . ' UTC', $apLastStatus, $apBurst)) . '</span>';
            }
            if ($apPauseUntil > 0) {
                echo '<br/><span class="description">' . esc_html(sprintf(__('Last auto-pause until: %s', 'wp-seo-next'), gmdate('Y-m-d H:i:s', $apPauseUntil) . ' UTC')) . '</span>';
            }
        } else {
            echo '<strong>' . esc_html__('Auto-pause:', 'wp-seo-next') . '</strong> ';
            echo '<span class="description">' . esc_html__('Disabled (see Settings → Performance budget).', 'wp-seo-next') . '</span>';
        }
        echo '</p>';

        if ($apEnabled && $apLastErr !== '') {
            echo '<details style="margin-top:8px;"><summary>' . esc_html__('Last 503/429 error (for debugging)', 'wp-seo-next') . '</summary>';
            echo '<pre style="white-space:pre-wrap;max-width:100%;overflow:auto;margin-top:8px;">' . esc_html($apLastErr) . '</pre>';
            echo '</details>';
        }

        echo '<p style="margin:6px 0 0 0;">';
        if ($paused) {
            $untilStr = $pauseUntil > 0 ? gmdate('Y-m-d H:i:s', $pauseUntil) . ' UTC' : '';
            echo '<strong>' . esc_html__('Queue is PAUSED.', 'wp-seo-next') . '</strong> ';
            if ($untilStr !== '') {
                echo '<span class="description">' . esc_html(sprintf(__('Until: %s', 'wp-seo-next'), $untilStr)) . '</span> ';
            }
            if ($pauseReason !== '') {
                echo '<span class="description">' . esc_html(sprintf(__('Reason: %s', 'wp-seo-next'), $pauseReason)) . '</span>';
            }
            if ($pauseForced) {
                echo ' <span class="description">' . esc_html__('(forced by wp-config)', 'wp-seo-next') . '</span>';
            }
        } else {
            echo '<strong>' . esc_html__('Queue is RUNNING.', 'wp-seo-next') . '</strong>';
        }
        echo '</p>';

        // Actions.
        echo '<div style="margin-top:12px; display:flex; flex-wrap:wrap; gap:8px;">';
        echo '<a class="button button-secondary" href="' . esc_url($run1) . '">' . esc_html__('Run worker (1 job)', 'wp-seo-next') . '</a>';
        if ($manualCap > 1) {
            $labelN = min(5, $manualCap);
            echo '<a class="button button-secondary" href="' . esc_url($run5) . '">' . esc_html(sprintf(__('Run worker (%d jobs)', 'wp-seo-next'), $labelN)) . '</a>';
        }
        echo '<a class="button button-primary" href="' . esc_url($runBatch) . '">' . esc_html__('Run safe batch (up to 10 jobs)', 'wp-seo-next') . '</a>';

        if ($paused) {
            if (!$pauseForced) {
                echo '<a class="button button-primary" href="' . esc_url($resume) . '">' . esc_html__('Resume queue', 'wp-seo-next') . '</a>';
            }
        } else {
            if (!$pauseForced) {
                echo '<a class="button button-secondary" href="' . esc_url($pause15) . '">' . esc_html__('Pause 15m', 'wp-seo-next') . '</a>';
                echo '<a class="button button-secondary" href="' . esc_url($pause60) . '">' . esc_html__('Pause 1h', 'wp-seo-next') . '</a>';
                echo '<a class="button button-secondary" href="' . esc_url($pause6h) . '">' . esc_html__('Pause 6h', 'wp-seo-next') . '</a>';
            }
        }

        echo '<span style="width:12px;display:inline-block;"></span>';
        echo '<a class="button button-secondary" href="' . esc_url($requeueFailed) . '">' . esc_html__('Requeue failed', 'wp-seo-next') . '</a>';
        echo '<a class="button button-secondary" href="' . esc_url($clearFailed) . '">' . esc_html__('Clear failed', 'wp-seo-next') . '</a>';
        if ((int) $metrics['stale_running'] > 0) {
            echo '<a class="button button-secondary" href="' . esc_url($releaseStale) . '">' . esc_html__('Release stale running jobs', 'wp-seo-next') . '</a>';
        }

        if ($apEnabled) {
            echo '<a class="button button-secondary" href="' . esc_url($clearAutoPause) . '">' . esc_html__('Clear auto-pause history', 'wp-seo-next') . '</a>';
        }
        echo '</div>';

        // Remote runner panel.
        echo '<hr style="margin:14px 0;" />';
        echo '<h2 style="margin:0 0 6px 0; font-size:16px;">' . esc_html__('External cron runner (optional)', 'wp-seo-next') . '</h2>';
        echo '<p class="description" style="margin:0 0 10px 0;">' . esc_html__('If WP-Cron is unreliable on your host, you can enable a secret URL and ping it from an external cron (every 1–5 minutes). It will process a small number of queue jobs per request.', 'wp-seo-next') . '</p>';

        $remoteSaveUrl = admin_url('admin-post.php?action=wpsn_queue_remote_update');
        echo '<form method="post" action="' . esc_url($remoteSaveUrl) . '" style="margin:0;">';
        wp_nonce_field('wpsn_queue_remote_update');

        echo '<label style="display:inline-flex; align-items:center; gap:8px;">';
        echo '<input type="checkbox" name="enabled" value="1" ' . checked($remoteEnabled, true, false) . ' /> ';
        echo '<strong>' . esc_html__('Enable remote runner', 'wp-seo-next') . '</strong>';
        echo '</label>';

        echo '<div style="margin-top:10px; display:flex; flex-wrap:wrap; gap:12px; align-items:center;">';
        echo '<label>' . esc_html__('Max jobs/request', 'wp-seo-next') . ': <input type="number" name="max_jobs" min="1" max="20" value="' . esc_attr((string) $remoteMaxJobs) . '" style="width:90px;" /></label>';
        echo '<label>' . esc_html__('Rate limit/hour', 'wp-seo-next') . ': <input type="number" name="rate_limit" min="10" max="5000" value="' . esc_attr((string) $remoteRateLimit) . '" style="width:110px;" /></label>';
        echo '<label>' . esc_html__('Window (sec)', 'wp-seo-next') . ': <input type="number" name="rate_window" min="60" max="' . esc_attr((string) (7 * DAY_IN_SECONDS)) . '" value="' . esc_attr((string) $remoteRateWindow) . '" style="width:110px;" /></label>';
        echo '</div>';

        echo '<div style="margin-top:10px; display:flex; flex-wrap:wrap; gap:8px; align-items:center;">';
        echo '<button type="submit" class="button button-primary">' . esc_html__('Save', 'wp-seo-next') . '</button>';
        echo '<label style="display:inline-flex; align-items:center; gap:6px;">';
        echo '<input type="checkbox" name="regenerate" value="1" /> ';
        echo esc_html__('Regenerate token', 'wp-seo-next');
        echo '</label>';
        echo '</div>';

        if ($remoteEnabled && $remoteUrl !== '') {
            echo '<div style="margin-top:10px;">';
            echo '<p style="margin:0 0 6px 0;"><strong>' . esc_html__('Cron URL:', 'wp-seo-next') . '</strong></p>';
            echo '<input type="text" readonly="readonly" value="' . esc_attr($remoteUrl) . '" style="width:100%; max-width:980px;" />';
            echo '<p class="description" style="margin:6px 0 0 0;">' . esc_html__('Tip: run it every 1–5 minutes. Example output is JSON with processed jobs and queue counts.', 'wp-seo-next') . '</p>';
            echo '<p style="margin:8px 0 0 0;"><a class="button button-secondary" href="' . esc_url($remoteUrl) . '" target="_blank" rel="noopener">' . esc_html__('Test now', 'wp-seo-next') . '</a></p>';
            echo '</div>';
        } elseif ($remoteEnabled && $remoteBase !== '') {
            echo '<p class="description" style="margin-top:10px;">' . esc_html__('Token will be generated after you click Save.', 'wp-seo-next') . '</p>';
        }

        echo '</form>';

        echo '</div>';

        if (!empty($familySummary)) {
            echo '<div class="wpsn-card" style="max-width:1200px; margin-top:12px;">';
            echo '<h2 style="margin-top:0;">' . esc_html__('What is queued', 'wp-seo-next') . '</h2>';
            echo '<p class="description" style="margin-top:-6px;">' . esc_html__('Grouped by job family so you can see what is filling the queue right now.', 'wp-seo-next') . '</p>';
            echo '<table class="widefat striped" style="margin-top:10px;">';
            echo '<thead><tr>';
            echo '<th>' . esc_html__('Family', 'wp-seo-next') . '</th>';
            echo '<th style="width:90px;">' . esc_html__('Pending', 'wp-seo-next') . '</th>';
            echo '<th style="width:90px;">' . esc_html__('Running', 'wp-seo-next') . '</th>';
            echo '<th style="width:90px;">' . esc_html__('Failed', 'wp-seo-next') . '</th>';
            echo '<th style="width:90px;">' . esc_html__('Done', 'wp-seo-next') . '</th>';
            echo '<th style="width:90px;">' . esc_html__('Total', 'wp-seo-next') . '</th>';
            echo '<th>' . esc_html__('Examples', 'wp-seo-next') . '</th>';
            echo '</tr></thead><tbody>';
            foreach ($familySummary as $family) {
                if (!is_array($family)) {
                    continue;
                }
                $examples = isset($family['examples']) && is_array($family['examples']) ? implode(', ', array_map('strval', $family['examples'])) : '';
                echo '<tr>';
                echo '<td><strong>' . esc_html((string) ($family['family'] ?? 'other')) . '</strong></td>';
                echo '<td>' . esc_html((string) ((int) ($family['pending'] ?? 0))) . '</td>';
                echo '<td>' . esc_html((string) ((int) ($family['running'] ?? 0))) . '</td>';
                echo '<td>' . esc_html((string) ((int) ($family['failed'] ?? 0))) . '</td>';
                echo '<td>' . esc_html((string) ((int) ($family['done'] ?? 0))) . '</td>';
                echo '<td>' . esc_html((string) ((int) ($family['total'] ?? 0))) . '</td>';
                echo '<td><code>' . esc_html($examples) . '</code></td>';
                echo '</tr>';
            }
            echo '</tbody></table>';
            echo '</div>';
        }

        // Filters.
        echo '<form method="get" class="wpsn-card" style="max-width:1200px; margin-top:12px;">';
        echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" />';
        if ($bulkSessionId !== '') {
            echo '<input type="hidden" name="bulk_session" value="' . esc_attr($bulkSessionId) . '" />';
            echo '<p style="margin-top:0;"><strong>' . esc_html__('Filtering jobs for bulk session', 'wp-seo-next') . ':</strong> <code>' . esc_html($bulkSessionId) . '</code> <a class="button button-link" href="' . esc_url(add_query_arg(['page' => self::PAGE_SLUG, 'status' => $status], admin_url('tools.php'))) . '">' . esc_html__('Clear session filter', 'wp-seo-next') . '</a></p>';
        }
        echo '<label style="margin-right:10px;">' . esc_html__('Status:', 'wp-seo-next') . ' ';
        echo '<select name="status">';
        $opts = [
            'pending' => __('Pending', 'wp-seo-next'),
            'running' => __('Running', 'wp-seo-next'),
            'failed' => __('Failed', 'wp-seo-next'),
            'done' => __('Done', 'wp-seo-next'),
            'all' => __('All', 'wp-seo-next'),
        ];
        foreach ($opts as $k => $lbl) {
            echo '<option value="' . esc_attr($k) . '" ' . selected($status, $k, false) . '>' . esc_html($lbl) . '</option>';
        }
        echo '</select></label>';

        echo '<label style="margin-right:10px;">' . esc_html__('Job type contains:', 'wp-seo-next') . ' ';
        echo '<input type="text" name="job_type" value="' . esc_attr($typeFilterRaw) . '" placeholder="audit_ / crawl_ / gsc_ …" style="min-width:240px;" />';
        echo '</label>';

        submit_button(__('Filter', 'wp-seo-next'), 'secondary', '', false);
        echo '</form>';

        // Table.
        echo '<div class="wpsn-card" style="max-width:1200px; margin-top:12px;">';
        echo '<h2 style="margin-top:0;">' . esc_html__('Jobs', 'wp-seo-next') . '</h2>';
        echo '<p class="description" style="margin-top:-6px;">' . esc_html(sprintf(__('Showing %d–%d of %d.', 'wp-seo-next'), min($total, $offset + 1), min($total, $offset + count($rows)), $total)) . '</p>';

        if (empty($rows)) {
            echo '<p>' . esc_html__('No jobs found for this filter.', 'wp-seo-next') . '</p>';
        } else {
            echo '<table class="widefat striped" style="margin-top:10px;">';
            echo '<thead><tr>';
            echo '<th style="width:70px;">' . esc_html__('ID', 'wp-seo-next') . '</th>';
            echo '<th style="width:90px;">' . esc_html__('Status', 'wp-seo-next') . '</th>';
            echo '<th>' . esc_html__('Job type', 'wp-seo-next') . '</th>';
            echo '<th style="width:160px;">' . esc_html__('Run at (UTC)', 'wp-seo-next') . '</th>';
            echo '<th style="width:70px;">' . esc_html__('Try', 'wp-seo-next') . '</th>';
            echo '<th>' . esc_html__('Last error', 'wp-seo-next') . '</th>';
            echo '<th style="width:240px;">' . esc_html__('Actions', 'wp-seo-next') . '</th>';
            echo '</tr></thead><tbody>';

            foreach ($rows as $r) {
                $id = (int) ($r['id'] ?? 0);
                $st = (string) ($r['status'] ?? '');
                $jt = (string) ($r['job_type'] ?? '');
                $runAt = (string) ($r['run_at'] ?? '');
                $attempts = (int) ($r['attempts'] ?? 0);
                $err = (string) ($r['last_error'] ?? '');
                $err = $err !== '' ? wp_trim_words($err, 18, '…') : '';

                $postpone15 = wp_nonce_url(admin_url('admin-post.php?action=wpsn_queue_postpone_job&id=' . $id . '&delay=900'), 'wpsn_queue_postpone_job');
                $postpone60 = wp_nonce_url(admin_url('admin-post.php?action=wpsn_queue_postpone_job&id=' . $id . '&delay=3600'), 'wpsn_queue_postpone_job');
                $requeue = wp_nonce_url(admin_url('admin-post.php?action=wpsn_queue_force_requeue&id=' . $id), 'wpsn_queue_force_requeue');
                $delete = wp_nonce_url(admin_url('admin-post.php?action=wpsn_queue_delete_job&id=' . $id), 'wpsn_queue_delete_job');

                echo '<tr>';
                echo '<td>' . esc_html((string) $id) . '</td>';
                echo '<td><code>' . esc_html($st) . '</code></td>';
                echo '<td><code>' . esc_html($jt) . '</code></td>';
                echo '<td><code>' . esc_html($runAt) . '</code></td>';
                echo '<td>' . esc_html((string) $attempts) . '</td>';
                echo '<td>' . esc_html($err) . '</td>';
                echo '<td>';
                echo '<a class="button button-small" href="' . esc_url($postpone15) . '">' . esc_html__('Postpone 15m', 'wp-seo-next') . '</a> ';
                echo '<a class="button button-small" href="' . esc_url($postpone60) . '">' . esc_html__('Postpone 1h', 'wp-seo-next') . '</a> ';
                echo '<a class="button button-small" href="' . esc_url($requeue) . '">' . esc_html__('Requeue', 'wp-seo-next') . '</a> ';
                $confirmMsg = __('Delete this job from the queue?', 'wp-seo-next');
                echo '<a class="button button-small" href="' . esc_url($delete) . '" onclick="return confirm(\'' . esc_js($confirmMsg) . '\');">' . esc_html__('Delete', 'wp-seo-next') . '</a>';
                echo '</td>';
                echo '</tr>';
            }

            echo '</tbody></table>';
        }

        // Pagination.
        if ($totalPages > 1) {
            $baseArgs = [
                'page' => self::PAGE_SLUG,
                'status' => $status,
                'job_type' => $typeFilterRaw,
            ];
            echo '<div style="margin-top:12px; display:flex; align-items:center; gap:10px;">';
            echo '<span class="description">' . esc_html(sprintf(__('Page %d of %d', 'wp-seo-next'), $paged, $totalPages)) . '</span>';
            if ($paged > 1) {
                $prevUrl = add_query_arg(array_merge($baseArgs, ['paged' => $paged - 1]), admin_url('tools.php'));
                echo '<a class="button button-secondary" href="' . esc_url($prevUrl) . '">' . esc_html__('← Prev', 'wp-seo-next') . '</a>';
            }
            if ($paged < $totalPages) {
                $nextUrl = add_query_arg(array_merge($baseArgs, ['paged' => $paged + 1]), admin_url('tools.php'));
                echo '<a class="button button-secondary" href="' . esc_url($nextUrl) . '">' . esc_html__('Next →', 'wp-seo-next') . '</a>';
            }
            echo '</div>';
        }

        echo '</div>'; // jobs card

        echo '</div>'; // wrap
    }
}
