<?php

namespace WPSN\Modules\Bulk\Admin;

use WPSN\Core\Logger;
use WPSN\Core\Branding;
use WPSN\Core\Capabilities;
use WPSN\Core\Queue\QueueInterface;
use WPSN\Core\Queue\QueueRunner;
use WPSN\Core\Settings;
use WPSN\Modules\AI\AISecretsRepository;
use WPSN\Modules\Bulk\BulkJobs;

defined('ABSPATH') || exit;

final class BulkAdmin
{
    private QueueInterface $queue;
    private QueueRunner $runner;
    private Settings $settings;
    private Logger $logger;

    private ?string $hookSuffix = null;

    private const AJAX_NONCE_ACTION = 'wpsn_bulk_ajax';

    public function __construct(QueueInterface $queue, QueueRunner $runner, Settings $settings, Logger $logger)
    {
        $this->queue = $queue;
        $this->runner = $runner;
        $this->settings = $settings;
        $this->logger = $logger;
    }

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

        add_action('admin_post_wpsn_bulk_queue_generate', [$this, 'handleQueueGenerate']);
        add_action('admin_post_wpsn_bulk_run_worker', [$this, 'handleRunWorker']);
        add_action('admin_post_wpsn_bulk_queue_apply_all', [$this, 'handleQueueApplyAll']);
        add_action('admin_post_wpsn_bulk_apply_single', [$this, 'handleApplySingle']);
        add_action('admin_post_wpsn_bulk_undo_single', [$this, 'handleUndoSingle']);

        add_action('admin_post_wpsn_bulk_clear_failed_jobs', [$this, 'handleClearFailedJobs']);
        add_action('admin_post_wpsn_bulk_requeue_failed_jobs', [$this, 'handleRequeueFailedJobs']);

        // AJAX: Apply-all with progress (admin only)
        add_action('wp_ajax_wpsn_bulk_apply_all_start', [$this, 'ajaxApplyAllStart']);
        add_action('wp_ajax_wpsn_bulk_apply_all_step', [$this, 'ajaxApplyAllStep']);
        add_action('wp_ajax_wpsn_bulk_apply_all_cancel', [$this, 'ajaxApplyAllCancel']);

        // AJAX: Generate drafts with progress (admin only)
        add_action('wp_ajax_wpsn_bulk_generate_start', [$this, 'ajaxGenerateStart']);
        add_action('wp_ajax_wpsn_bulk_generate_step', [$this, 'ajaxGenerateStep']);
        add_action('wp_ajax_wpsn_bulk_generate_cancel', [$this, 'ajaxGenerateCancel']);
    }

    public function addMenu(): void
    {
        $this->hookSuffix = add_management_page(
            __('SEO Bulk Optimize', 'wp-seo-next'),
            __('SEO Bulk Optimize', 'wp-seo-next'),
            Capabilities::CAP_TOOLS,
            'wpsn-seo-bulk',
            [$this, 'render']
        );
    }

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

        wp_enqueue_style(
            'wpsn-bulk-admin',
            WPSN_PLUGIN_URL . 'assets/admin/bulk.css',
            [],
            WPSN_VERSION
        );

        wp_enqueue_script(
            'wpsn-bulk-admin',
            WPSN_PLUGIN_URL . 'assets/admin/bulk.js',
            [],
            WPSN_VERSION,
            true
        );

        wp_localize_script('wpsn-bulk-admin', 'WPSN_BULK', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'nonce' => wp_create_nonce(self::AJAX_NONCE_ACTION),
            'strings' => [
                'starting' => __('Starting…', 'wp-seo-next'),
                'processing' => __('Processing…', 'wp-seo-next'),
                'done' => __('Done.', 'wp-seo-next'),
                'cancelled' => __('Cancelled.', 'wp-seo-next'),
                'error' => __('Error.', 'wp-seo-next'),
            ],
        ]);
    }

    private function pageUrl(array $args = []): string
    {
        $base = admin_url('tools.php');
        $args = array_merge(['page' => 'wpsn-seo-bulk'], $args);
        return (string) add_query_arg($args, $base);
    }

    private function redirectWithNotice(string $msg, string $type = 'success'): void
    {
        $url = $this->pageUrl([
            'wpsn_bulk_notice' => rawurlencode($msg),
            'wpsn_bulk_notice_type' => $type,
        ]);

        wp_safe_redirect($url);
        exit;
    }

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

        $counts = $this->runner->counts();

        $secrets = new AISecretsRepository();
        $hasKey = $secrets->hasOpenAIKey();

        $draftCount = $this->countSuggestionsByStatus('draft');
        $appliedCount = $this->countSuggestionsByStatus('applied');

        $notice = isset($_GET['wpsn_bulk_notice']) ? sanitize_text_field((string) wp_unslash($_GET['wpsn_bulk_notice'])) : '';
        $noticeType = isset($_GET['wpsn_bulk_notice_type']) ? sanitize_key((string) wp_unslash($_GET['wpsn_bulk_notice_type'])) : 'success';
        if (!in_array($noticeType, ['success', 'error', 'warning', 'info'], true)) {
            $noticeType = 'success';
        }

        $aiExternalEnabled = (bool) $this->settings->get('ai.external.enabled', false);
        $aiLocalEnabled = (bool) $this->settings->get('ai.local.enabled', true);

        $postTypes = get_post_types(['public' => true], 'objects');
        unset($postTypes['attachment']);

        // View + filters for suggestions browser.
        $view = isset($_GET['view']) ? sanitize_key((string) wp_unslash($_GET['view'])) : 'draft';
        if (!in_array($view, ['draft', 'applied', 'failed'], true)) {
            $view = 'draft';
        }

        $search = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
        $ptFilter = isset($_GET['pt']) ? sanitize_key((string) wp_unslash($_GET['pt'])) : 'any';
        if ($ptFilter !== 'any' && $ptFilter !== '' && !post_type_exists($ptFilter)) {
            $ptFilter = 'any';
        }

        $sourceFilter = isset($_GET['source']) ? sanitize_key((string) wp_unslash($_GET['source'])) : '';
        if (!in_array($sourceFilter, ['', 'local', 'openai'], true)) {
            $sourceFilter = '';
        }

        // Optional: focus on a single post (deep link from editor/builder overlays).
        $postIdFilter = isset($_GET['post_id']) ? (int) $_GET['post_id'] : 0;
        if ($postIdFilter > 0) {
            $p = get_post($postIdFilter);
            if (!$p || $p->post_type === 'revision') {
                $postIdFilter = 0;
            }
        }

        $perPage = isset($_GET['per_page']) ? (int) $_GET['per_page'] : 50;
        $perPage = max(10, min(200, $perPage));

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

        // Suggestions browser query (only for draft/applied).
        $suggestionsQuery = null;
        if ($view === 'draft' || $view === 'applied') {
            $suggestionsQuery = $this->buildSuggestionsQuery($view, $search, $ptFilter, $sourceFilter, $perPage, $paged, $postIdFilter);
        }

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

            <?php if ($notice !== '') : ?>
                <div class="notice notice-<?php echo esc_attr($noticeType); ?> is-dismissible"><p><?php echo esc_html($notice); ?></p></div>
            <?php endif; ?>

            <p class="description">
                <?php echo esc_html__('Generate draft SEO suggestions (Local or OpenAI) for many posts, then apply them safely (with undo).', 'wp-seo-next'); ?>
            </p>

            <h2><?php echo esc_html__('Queue status', 'wp-seo-next'); ?></h2>
            <table class="widefat striped" style="max-width: 920px;">
                <tbody>
                    <tr>
                        <th><?php echo esc_html__('Pending', 'wp-seo-next'); ?></th><td><?php echo (int) ($counts['pending'] ?? 0); ?></td>
                        <th><?php echo esc_html__('Running', 'wp-seo-next'); ?></th><td><?php echo (int) ($counts['running'] ?? 0); ?></td>
                        <th><?php echo esc_html__('Done', 'wp-seo-next'); ?></th><td><?php echo (int) ($counts['done'] ?? 0); ?></td>
                        <th><?php echo esc_html__('Failed', 'wp-seo-next'); ?></th><td><?php echo (int) ($counts['failed'] ?? 0); ?></td>
                    </tr>
                </tbody>
            </table>

            <div style="margin-top: 10px;">
                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline-block; margin-right: 10px;">
                    <input type="hidden" name="action" value="wpsn_bulk_requeue_failed_jobs" />
                    <?php wp_nonce_field('wpsn_bulk_requeue_failed_jobs', 'wpsn_nonce'); ?>
                    <button class="button button-secondary"><?php echo esc_html__('Requeue failed jobs', 'wp-seo-next'); ?></button>
                </form>

                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline-block;">
                    <input type="hidden" name="action" value="wpsn_bulk_clear_failed_jobs" />
                    <?php wp_nonce_field('wpsn_bulk_clear_failed_jobs', 'wpsn_nonce'); ?>
                    <button class="button button-link-delete"><?php echo esc_html__('Clear failed jobs', 'wp-seo-next'); ?></button>
                </form>

                <p class="description" style="margin-top: 8px;">
                    <?php
                        $queueUrl = admin_url('tools.php?page=wpsn-queue');
                        echo esc_html__('Jobs run in the background via WP-Cron / server cron. If you need manual controls, use Queue Center.', 'wp-seo-next');
                        echo ' ';
                        echo '<a href="' . esc_url($queueUrl) . '">' . esc_html__('Open Queue Center', 'wp-seo-next') . '</a>';
                    ?>
                </p>

                <details style="margin-top:10px;">
                    <summary><?php echo esc_html__('Advanced: run worker manually', 'wp-seo-next'); ?></summary>
                    <div style="margin-top: 10px;">
                        <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline-block; margin-right: 10px;">
                            <input type="hidden" name="action" value="wpsn_bulk_run_worker" />
                            <?php wp_nonce_field('wpsn_bulk_run_worker', 'wpsn_nonce'); ?>
                            <label>
                                <?php echo esc_html__('Run worker (jobs):', 'wp-seo-next'); ?>
                                <input type="number" min="1" max="50" name="max_jobs" value="10" style="width:80px;" />
                            </label>
                            <button class="button button-secondary"><?php echo esc_html__('Run', 'wp-seo-next'); ?></button>
                        </form>
                    </div>
                </details>
            </div>

            <?php if ((int) ($counts['failed'] ?? 0) > 0) : ?>
                <p class="description" style="margin-top: 10px;">
                    <a href="<?php echo esc_url($this->pageUrl(['view' => 'failed'])); ?>">
                        <?php echo esc_html(sprintf(__('View failed jobs (%d)', 'wp-seo-next'), (int) ($counts['failed'] ?? 0))); ?>
                    </a>
                </p>
            <?php endif; ?>

            <hr/>

            <h2><?php echo esc_html__('Bulk generate draft suggestions', 'wp-seo-next'); ?></h2>
            <p class="description">
                <?php
                    $settingsUrl = admin_url('admin.php?page=wp-seo-next');
                    $brand = Branding::brand($this->settings);
                    $settingsText = $brand !== ''
                        ? ($brand . ' → ' . __('Settings', 'wp-seo-next') . ' → ' . __('AI', 'wp-seo-next'))
                        : (__('Settings', 'wp-seo-next') . ' → ' . __('AI', 'wp-seo-next'));
                    printf(
                        /* translators: %s is settings URL */
                        esc_html__('Configure AI settings here: %s', 'wp-seo-next'),
                        sprintf('<a href="%s">%s</a>', esc_url($settingsUrl), esc_html($settingsText))
                    );
                ?>
            </p>

            <form id="wpsn-bulk-generate-form" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="max-width: 920px;">
                <input type="hidden" name="action" value="wpsn_bulk_queue_generate" />
                <?php wp_nonce_field('wpsn_bulk_queue_generate', 'wpsn_nonce'); ?>

                <table class="form-table" role="presentation">
                    <tr>
                        <th scope="row"><?php echo esc_html__('Source', 'wp-seo-next'); ?></th>
                        <td>
                            <select id="wpsn-bulk-generate-source" name="source">
                                <option value="local" <?php selected(true, true); ?>><?php echo esc_html__('Local (no external calls)', 'wp-seo-next'); ?></option>
                                <option value="openai" <?php disabled(!$aiExternalEnabled || !$hasKey); ?>><?php echo esc_html__('OpenAI (external)', 'wp-seo-next'); ?></option>
                            </select>

                            <p class="description">
                                <?php if (!$aiLocalEnabled) : ?>
                                    <span style="color:#a00; font-weight:600;"><?php echo esc_html__('Local suggestions are disabled in Settings.', 'wp-seo-next'); ?></span><br/>
                                <?php endif; ?>
                                <?php if (!$aiExternalEnabled) : ?>
                                    <span style="color:#a00; font-weight:600;"><?php echo esc_html__('OpenAI generation is disabled in Settings.', 'wp-seo-next'); ?></span><br/>
                                <?php endif; ?>
                                <?php if (!$hasKey) : ?>
                                    <span style="color:#a00; font-weight:600;"><?php echo esc_html__('OpenAI API key is not set.', 'wp-seo-next'); ?></span>
                                <?php endif; ?>
                            </p>
                        </td>
                    </tr>

                    <tr>
                        <th scope="row"><?php echo esc_html__('Fields', 'wp-seo-next'); ?></th>
                        <td>
                            <label style="margin-right: 12px;">
                                <input type="checkbox" name="fields[title]" value="1" checked />
                                <?php echo esc_html__('SEO title', 'wp-seo-next'); ?>
                            </label>
                            <label>
                                <input type="checkbox" name="fields[description]" value="1" checked />
                                <?php echo esc_html__('Meta description', 'wp-seo-next'); ?>
                            </label>
                        </td>
                    </tr>

                    <tr>
                        <th scope="row"><?php echo esc_html__('Post types', 'wp-seo-next'); ?></th>
                        <td>
                            <?php foreach ($postTypes as $slug => $pt) : ?>
                                <label style="display:inline-block; margin: 0 14px 6px 0;">
                                    <input type="checkbox" name="post_types[<?php echo esc_attr($slug); ?>]" value="1" checked />
                                    <?php echo esc_html($pt->labels->singular_name ?? $slug); ?>
                                </label>
                            <?php endforeach; ?>
                            <p class="description"><?php echo esc_html__('Only published posts are queued.', 'wp-seo-next'); ?></p>
                        </td>
                    </tr>

                    <tr>
                        <th scope="row"><?php echo esc_html__('Options', 'wp-seo-next'); ?></th>
                        <td>
                            <label style="display:block; margin: 2px 0;">
                                <input type="checkbox" name="only_if_empty" value="1" checked />
                                <?php echo esc_html__('Only generate suggestions if the corresponding SEO field is empty or broken', 'wp-seo-next'); ?>
                            </label>
                            <label style="display:block; margin: 2px 0;">
                                <input type="checkbox" name="overwrite_suggestions" value="1" />
                                <?php echo esc_html__('Overwrite existing suggestions (if any)', 'wp-seo-next'); ?>
                            </label>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><?php echo esc_html__('Max posts to queue', 'wp-seo-next'); ?></th>
                        <td>
                            <input id="wpsn-bulk-generate-limit" type="number" min="1" max="20000" name="limit" value="500" style="width: 120px;" />
                            <p class="description"><?php echo esc_html__('Safety limit to avoid queuing too much at once. You can run multiple batches.', 'wp-seo-next'); ?></p>
                        </td>
                    </tr>
                </table>
                <p>
                    <button class="button button-primary"><?php echo esc_html__('Queue generation jobs', 'wp-seo-next'); ?></button>
                </p>

                <p class="description" style="margin-top: 6px;">
                    <?php echo esc_html__('Queue mode is recommended on busy hosting. Live mode (AJAX) is optional and may trigger HTTP 503 on some servers.', 'wp-seo-next'); ?>
                </p>

                <details class="wpsn-advanced wpsn-ajax-optional" style="max-width: 920px; margin-top: 10px;">
                    <summary><?php echo esc_html__('Advanced: Live mode (AJAX)', 'wp-seo-next'); ?></summary>

                    <p class="description" style="margin-top: 8px;">
                        <?php echo esc_html__('Live mode runs immediately in your browser session and shows progress. If your host rate-limits admin-ajax.php, prefer Queue mode.', 'wp-seo-next'); ?>
                    </p>

                    <p style="margin-top: 10px;">
                        <label>
                            <?php echo esc_html__('Wait on rate limit (seconds):', 'wp-seo-next'); ?>
                            <input id="wpsn-bulk-backoff-seconds" type="number" min="1" max="120" name="backoff_seconds" value="10" style="width:120px;" />
                        </label>
                        <span class="description" style="margin-left:10px;">
                            <?php echo esc_html__('Used only by AJAX live mode.', 'wp-seo-next'); ?>
                        </span>
                    </p>

                    <p>
                        <button type="button" id="wpsn-bulk-ajax-generate-now" class="button button-secondary">
                            <?php echo esc_html__('Generate drafts now (AJAX)', 'wp-seo-next'); ?>
                        </button>
                        <span class="spinner" id="wpsn-bulk-ajax-generate-spinner" style="float:none;"></span>
                    </p>

                    <div id="wpsn-bulk-ajax-generate-progress" class="wpsn-bulk-progress" style="display:none;">
                        <div class="wpsn-progress">
                            <div id="wpsn-bulk-ajax-generate-bar" class="wpsn-progress__bar" style="width:0%;"></div>
                        </div>
                        <p id="wpsn-bulk-ajax-generate-status" class="description" style="margin-top: 8px;"></p>
                        <p style="margin-top: 8px;">
                            <button type="button" class="button" id="wpsn-bulk-ajax-generate-cancel"><?php echo esc_html__('Cancel', 'wp-seo-next'); ?></button>
                        </p>
                    </div>
                </details>
            </form>

            <hr/>

            <h2 class="nav-tab-wrapper" style="padding-top:0;">
                <?php
                    $draftTabUrl = $this->pageUrl([
                        'view' => 'draft',
                        's' => $search,
                        'pt' => $ptFilter,
                        'source' => $sourceFilter,
                        'per_page' => $perPage,
                    ]);
                    $appliedTabUrl = $this->pageUrl([
                        'view' => 'applied',
                        's' => $search,
                        'pt' => $ptFilter,
                        'source' => $sourceFilter,
                        'per_page' => $perPage,
                    ]);

                    $failedTabUrl = $this->pageUrl([
                        'view' => 'failed',
                        'per_page' => $perPage,
                    ]);
                ?>
                <a href="<?php echo esc_url($draftTabUrl); ?>" class="nav-tab <?php echo $view === 'draft' ? 'nav-tab-active' : ''; ?>">
                    <?php echo esc_html(sprintf(__('Draft suggestions (%d)', 'wp-seo-next'), (int) $draftCount)); ?>
                </a>
                <a href="<?php echo esc_url($appliedTabUrl); ?>" class="nav-tab <?php echo $view === 'applied' ? 'nav-tab-active' : ''; ?>">
                    <?php echo esc_html(sprintf(__('Applied / Undo (%d)', 'wp-seo-next'), (int) $appliedCount)); ?>
                </a>
                <a href="<?php echo esc_url($failedTabUrl); ?>" class="nav-tab <?php echo $view === 'failed' ? 'nav-tab-active' : ''; ?>">
                    <?php echo esc_html(sprintf(__('Failed jobs (%d)', 'wp-seo-next'), (int) ($counts['failed'] ?? 0))); ?>
                </a>
            </h2>

            <?php if ($view === 'draft') : ?>
                <h2 style="margin-top: 10px;"><?php echo esc_html__('Apply all (drafts)', 'wp-seo-next'); ?></h2>
                <p class="description"><?php echo esc_html__('Apply draft suggestions to SEO fields (title/description). Queue mode is recommended on busy hosting; live mode (AJAX) is optional.', 'wp-seo-next'); ?></p>

                <form id="wpsn-bulk-apply-form" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin: 10px 0; max-width: 920px;">
                    <input type="hidden" name="action" value="wpsn_bulk_queue_apply_all" />
                    <?php wp_nonce_field('wpsn_bulk_queue_apply_all', 'wpsn_nonce'); ?>
                    <label>
                        <?php echo esc_html__('Max posts to apply:', 'wp-seo-next'); ?>
                        <input id="wpsn-bulk-apply-limit" type="number" min="1" max="20000" name="limit" value="500" style="width:120px;" />
                    </label>
                    <label style="margin-left:10px;">
                        <input type="checkbox" id="wpsn-bulk-apply-only-changed" name="apply_only_changed" value="1" checked />
                        <?php echo esc_html__('Apply only if changed (skip no-op updates)', 'wp-seo-next'); ?>
                    </label>
                    <button class="button button-primary" style="margin-left:6px;"><?php echo esc_html__('Queue apply-all (drafts)', 'wp-seo-next'); ?></button>

                    <details class="wpsn-advanced wpsn-ajax-optional" style="margin-top: 10px;">
                        <summary><?php echo esc_html__('Advanced: Live mode (AJAX)', 'wp-seo-next'); ?></summary>

                        <p class="description" style="margin-top: 8px;">
                            <?php echo esc_html__('Live mode runs immediately in your browser session and shows progress. If your host rate-limits admin-ajax.php, prefer Queue mode.', 'wp-seo-next'); ?>
                        </p>

                        <p style="margin-top: 10px;">
                            <button type="button" id="wpsn-bulk-ajax-apply-all" class="button button-secondary">
                                <?php echo esc_html__('Apply all now (AJAX)', 'wp-seo-next'); ?>
                            </button>
                            <span class="spinner" id="wpsn-bulk-ajax-spinner" style="float:none;"></span>
                        </p>

                        <p class="description" style="margin-top: 8px;">
                            <?php echo esc_html__('Tip: if OpenAI jobs are failing (rate limits), use “Requeue failed jobs” above after a cooldown.', 'wp-seo-next'); ?>
                        </p>

                        <div id="wpsn-bulk-ajax-progress" class="wpsn-bulk-progress" style="display:none;">
                            <div class="wpsn-progress">
                                <div id="wpsn-bulk-ajax-bar" class="wpsn-progress__bar" style="width:0%;"></div>
                            </div>
                            <p id="wpsn-bulk-ajax-status" class="description" style="margin-top: 8px;"></p>
                            <p style="margin-top: 8px;">
                                <button type="button" class="button" id="wpsn-bulk-ajax-cancel"><?php echo esc_html__('Cancel', 'wp-seo-next'); ?></button>
                            </p>
                        </div>
                    </details>
                </form>
            <?php elseif ($view === 'applied') : ?>
                <h2 style="margin-top: 10px;"><?php echo esc_html__('Undo', 'wp-seo-next'); ?></h2>
                <p class="description"><?php echo esc_html__('Undo restores previous SEO title/description from the snapshot history. Snapshots are kept as a stack (last applied = first undone).', 'wp-seo-next'); ?></p>
            <?php else : ?>
                <h2 style="margin-top: 10px;"><?php echo esc_html__('Failed jobs', 'wp-seo-next'); ?></h2>
                <p class="description"><?php echo esc_html__('These background jobs failed in the queue worker. Filter by job type to debug (rate limits, missing keys, timeouts, etc.).', 'wp-seo-next'); ?></p>
            <?php endif; ?>

            <?php if ($view === 'draft' || $view === 'applied') : ?>
                <?php
                    // @phpstan-ignore-next-line
                    $q = $suggestionsQuery instanceof \WP_Query ? $suggestionsQuery : new \WP_Query();
                ?>
                <?php $this->renderSuggestionsFilters($view, $search, $ptFilter, $sourceFilter, $perPage, $postIdFilter); ?>

                <?php $this->renderPagination($q, $view, $search, $ptFilter, $sourceFilter, $perPage, $paged, $postIdFilter); ?>

                <?php $this->renderSuggestionsTable($q, $view); ?>

                <?php $this->renderPagination($q, $view, $search, $ptFilter, $sourceFilter, $perPage, $paged, $postIdFilter); ?>
            <?php else : ?>
                <?php
                    $jobTypeFilter = isset($_GET['job_type']) ? sanitize_key((string) wp_unslash($_GET['job_type'])) : '';
                ?>
                <?php $this->renderFailedJobsFilters($jobTypeFilter, $perPage); ?>
                <?php $this->renderFailedJobsTable($jobTypeFilter, $perPage, $paged); ?>
            <?php endif; ?>

        </div>
        <?php
    }

    public function handleRunWorker(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_bulk_run_worker', 'wpsn_nonce');

        $maxJobs = isset($_POST['max_jobs']) ? (int) $_POST['max_jobs'] : 10;
        $processed = $this->runner->run(max(1, min(50, $maxJobs)));

        $this->redirectWithNotice(sprintf(__('Worker processed %d job(s).', 'wp-seo-next'), $processed), 'success');
    }

    public function handleClearFailedJobs(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_bulk_clear_failed_jobs', 'wpsn_nonce');

        $deleted = $this->queue->clearFailed();
        $this->redirectWithNotice(sprintf(__('Cleared %d failed job(s).', 'wp-seo-next'), $deleted), 'success');
    }

    public function handleRequeueFailedJobs(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_bulk_requeue_failed_jobs', 'wpsn_nonce');

        $updated = $this->queue->requeueFailed();
        $this->redirectWithNotice(sprintf(__('Requeued %d failed job(s).', 'wp-seo-next'), $updated), 'success');
    }

    public function handleQueueGenerate(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_bulk_queue_generate', 'wpsn_nonce');

        $source = isset($_POST['source']) ? sanitize_key((string) wp_unslash($_POST['source'])) : 'local';
        if (!in_array($source, ['local', 'openai'], true)) {
            $source = 'local';
        }

        if ($source === 'openai' && !Capabilities::canUseAI()) {
            $this->redirectWithNotice(__('You do not have permission to use OpenAI generation.', 'wp-seo-next'), 'error');
        }

        // Pre-check settings to avoid queuing jobs that will instantly fail.
        if ($source === 'local' && !(bool) $this->settings->get('ai.local.enabled', true)) {
            $this->redirectWithNotice(__('Local suggestions are disabled in Settings.', 'wp-seo-next'), 'error');
        }

        if ($source === 'openai') {
            $secrets = new AISecretsRepository();
            if (!(bool) $this->settings->get('ai.external.enabled', false)) {
                $this->redirectWithNotice(__('OpenAI generation is disabled in Settings.', 'wp-seo-next'), 'error');
            }
            if (!$secrets->hasOpenAIKey()) {
                $this->redirectWithNotice(__('OpenAI API key is not configured.', 'wp-seo-next'), 'error');
            }
        }

        $fields = [];
        if (isset($_POST['fields']) && is_array($_POST['fields'])) {
            if (!empty($_POST['fields']['title'])) {
                $fields[] = 'title';
            }
            if (!empty($_POST['fields']['description'])) {
                $fields[] = 'description';
            }
        }
        if (empty($fields)) {
            $fields = ['title', 'description'];
        }

        $postTypes = [];
        if (isset($_POST['post_types']) && is_array($_POST['post_types'])) {
            foreach ($_POST['post_types'] as $pt => $v) {
                if (empty($v)) {
                    continue;
                }
                $pt = sanitize_key((string) $pt);
                if ($pt !== '' && post_type_exists($pt)) {
                    $postTypes[] = $pt;
                }
            }
        }
        if (empty($postTypes)) {
            $postTypes = ['post', 'page'];
        }

        $onlyIfEmpty = !empty($_POST['only_if_empty']);
        $overwrite = !empty($_POST['overwrite_suggestions']);

        $backoffSeconds = isset($_POST['backoff_seconds']) ? (int) $_POST['backoff_seconds'] : 10;
        $backoffSeconds = max(1, min(120, $backoffSeconds));

        $limit = isset($_POST['limit']) ? (int) $_POST['limit'] : 500;
        if ($limit < 1) {
            $limit = 500;
        }
        if ($limit > 20000) {
            $limit = 20000;
        }

        global $wpdb;
        $typesPlaceholders = implode(',', array_fill(0, count($postTypes), '%s'));
        $params = $postTypes;
        $params[] = $limit;

        $sql = "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$typesPlaceholders}) ORDER BY ID DESC LIMIT %d";
        $ids = $wpdb->get_col($wpdb->prepare($sql, $params));

        $queued = 0;
        if (is_array($ids)) {
            foreach ($ids as $id) {
                $pid = (int) $id;
                if ($pid <= 0) {
                    continue;
                }
                $this->queue->enqueue('bulk_generate', [
                    'post_id' => $pid,
                    'user_id' => (int) get_current_user_id(),
                    'source' => $source,
                    'fields' => $fields,
                    'only_if_empty' => $onlyIfEmpty,
                    'overwrite_suggestions' => $overwrite,
                    'backoff_seconds' => $backoffSeconds,
                ]);
                $queued++;
            }
        }

        $this->redirectWithNotice(sprintf(__('Queued %d generation job(s).', 'wp-seo-next'), $queued), 'success');
    }

    public function handleQueueApplyAll(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission.', 'wp-seo-next'));
        }
        check_admin_referer('wpsn_bulk_queue_apply_all', 'wpsn_nonce');

        $applyOnlyChanged = isset($_POST['apply_only_changed']) && (string) wp_unslash($_POST['apply_only_changed']) === '1';

        $limit = isset($_POST['limit']) ? (int) $_POST['limit'] : 500;
        if ($limit < 1) {
            $limit = 500;
        }
        if ($limit > 20000) {
            $limit = 20000;
        }

        global $wpdb;

        $pm = $wpdb->postmeta;
        $p = $wpdb->posts;

        $ids = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT pm.post_id
                 FROM {$pm} pm
                 INNER JOIN {$p} p ON p.ID = pm.post_id
                 WHERE pm.meta_key = %s AND pm.meta_value = %s
                   AND p.post_status IN ('publish','private','draft')
                 ORDER BY pm.post_id DESC
                 LIMIT %d",
                BulkJobs::META_STATUS,
                'draft',
                $limit
            )
        );

        $queued = 0;
        if (is_array($ids)) {
            foreach ($ids as $id) {
                $pid = (int) $id;
                if ($pid <= 0) {
                    continue;
                }
                $this->queue->enqueue('bulk_apply', [
                    'post_id' => $pid,
                    'apply_only_changed' => $applyOnlyChanged ? 1 : 0,
                ]);
                $queued++;
            }
        }

        /**
         * Fired when Bulk apply jobs are queued (non-AJAX).
         *
         * @param int $queued
         * @param array<string,mixed> $meta
         */
        do_action('wpsn/bulk/apply_queued', (int) $queued, [
            'source' => 'queue',
            'apply_only_changed' => $applyOnlyChanged ? 1 : 0,
            'limit' => $limit,
        ]);

        $this->redirectWithNotice(sprintf(__('Queued %d apply job(s).', 'wp-seo-next'), $queued), 'success');
    }

    public function handleApplySingle(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission.', 'wp-seo-next'));
        }
        $postId = isset($_GET['post_id']) ? (int) $_GET['post_id'] : 0;
        check_admin_referer('wpsn_bulk_apply_' . $postId, 'wpsn_nonce');

        $jobs = new \WPSN\Modules\Bulk\BulkJobs($this->settings, $this->logger);
        $jobs->handleApply(null, ['post_id' => $postId], []);

        $this->redirectWithNotice(__('Applied suggestion.', 'wp-seo-next'), 'success');
    }

    public function handleUndoSingle(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_die(esc_html__('You do not have permission.', 'wp-seo-next'));
        }
        $postId = isset($_GET['post_id']) ? (int) $_GET['post_id'] : 0;
        check_admin_referer('wpsn_bulk_undo_' . $postId, 'wpsn_nonce');

        $raw = (string) get_post_meta($postId, BulkJobs::META_UNDO, true);
        if ($raw === '') {
            $this->redirectWithNotice(__('No undo snapshot found.', 'wp-seo-next'), 'warning');
        }

        $snap = json_decode($raw, true);
        $meta = null;

        if (is_array($snap)) {
            // v2: history stack (preferred)
            if (isset($snap['history']) && is_array($snap['history']) && !empty($snap['history'])) {
                $entry = array_pop($snap['history']);
                if (is_array($entry) && isset($entry['meta']) && is_array($entry['meta'])) {
                    $meta = $entry['meta'];

                    // Persist remaining history (or remove if empty).
                    if (empty($snap['history'])) {
                        delete_post_meta($postId, BulkJobs::META_UNDO);
                    } else {
                        update_post_meta($postId, BulkJobs::META_UNDO, wp_json_encode($snap));
                    }
                }
            } elseif (isset($snap['meta']) && is_array($snap['meta'])) {
                // v1 snapshot compatibility.
                $meta = $snap['meta'];
                delete_post_meta($postId, BulkJobs::META_UNDO);
            }
        }

        if (!is_array($meta)) {
            $this->redirectWithNotice(__('Invalid undo snapshot.', 'wp-seo-next'), 'error');
        }

        foreach ($meta as $k => $v) {
            $k = (string) $k;
            $v = (string) $v;
            if ($v === '') {
                delete_post_meta($postId, $k);
            } else {
                update_post_meta($postId, $k, $v);
            }
        }

        // Mark suggestion back to draft (so user can re-apply).
        $sRaw = (string) get_post_meta($postId, BulkJobs::META_SUGGESTIONS, true);
        if ($sRaw !== '') {
            $s = json_decode($sRaw, true);
            if (is_array($s)) {
                $s['status'] = 'draft';
                unset($s['applied_at']);
                update_post_meta($postId, BulkJobs::META_SUGGESTIONS, wp_json_encode($s));
            }
        }
        update_post_meta($postId, BulkJobs::META_STATUS, 'draft');
        update_post_meta($postId, BulkJobs::META_UPDATED, time());

        do_action('seo/meta/updated', $postId, 'post');

        $this->redirectWithNotice(__('Undo applied.', 'wp-seo-next'), 'success');
    }

    /**
     * AJAX: Start apply-all session
     */
    public function ajaxApplyAllStart(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_send_json_error(['message' => __('You do not have permission.', 'wp-seo-next')], 403);
        }
        check_ajax_referer(self::AJAX_NONCE_ACTION, 'nonce');

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

        // Optional: targeted run for a specific list of post IDs.
        // Used by other tools (e.g. Search Console Opportunities) to generate drafts for selected pages.
        $idsStr = isset($_POST['ids']) ? sanitize_text_field((string) wp_unslash($_POST['ids'])) : '';
        $idsFromRequest = [];
        if ($idsStr !== '') {
            $raw = array_filter(array_map('trim', explode(',', $idsStr)));
            $raw = array_slice($raw, 0, 50000);
            $rawIds = array_values(array_unique(array_filter(array_map('intval', $raw), static fn ($v) => $v > 0)));

            foreach ($rawIds as $pid) {
                $p = get_post((int) $pid);
                if (!$p || $p->post_type === 'revision') {
                    continue;
                }
                if ($p->post_status !== 'publish') {
                    continue;
                }
                if (!current_user_can('edit_post', (int) $pid)) {
                    continue;
                }
                $idsFromRequest[] = (int) $pid;
                if (count($idsFromRequest) >= $limit) {
                    break;
                }
            }
        }

        $applyOnlyChanged = isset($_POST['apply_only_changed']) && (string) wp_unslash($_POST['apply_only_changed']) === '1';

        $search = isset($_POST['s']) ? sanitize_text_field((string) wp_unslash($_POST['s'])) : '';
        $ptFilter = isset($_POST['pt']) ? sanitize_key((string) wp_unslash($_POST['pt'])) : 'any';
        if ($ptFilter !== 'any' && $ptFilter !== '' && !post_type_exists($ptFilter)) {
            $ptFilter = 'any';
        }
        $sourceFilter = isset($_POST['source']) ? sanitize_key((string) wp_unslash($_POST['source'])) : '';
        if (!in_array($sourceFilter, ['', 'local', 'openai'], true)) {
            $sourceFilter = '';
        }

        $postIdFilter = isset($_POST['post_id']) ? (int) $_POST['post_id'] : 0;
        if ($postIdFilter > 0) {
            $p = get_post($postIdFilter);
            if (!$p || $p->post_type === 'revision') {
                $postIdFilter = 0;
            }
        }

        // Optional: targeted apply for a specific list of post IDs.
        // Used by other tools (e.g. Search Console Opportunities) to apply drafts for selected pages.
        $idsStr = isset($_POST['ids']) ? sanitize_text_field((string) wp_unslash($_POST['ids'])) : '';
        $ids = [];
        if ($idsStr !== '') {
            $raw = array_filter(array_map('trim', explode(',', $idsStr)));
            $raw = array_slice($raw, 0, 50000);
            $rawIds = array_values(array_unique(array_filter(array_map('intval', $raw), static fn ($v) => $v > 0)));

            foreach ($rawIds as $pid) {
                $p = get_post((int) $pid);
                if (!$p || $p->post_type === 'revision') {
                    continue;
                }
                if (!current_user_can('edit_post', (int) $pid)) {
                    continue;
                }

                // Only include posts that currently have draft suggestions.
                $status = (string) get_post_meta((int) $pid, BulkJobs::META_STATUS, true);
                if ($status !== 'draft') {
                    continue;
                }

                // Respect source filter if provided.
                if ($sourceFilter !== '') {
                    $src = (string) get_post_meta((int) $pid, BulkJobs::META_SOURCE, true);
                    if ($src !== $sourceFilter) {
                        continue;
                    }
                }

                $ids[] = (int) $pid;
                if (count($ids) >= $limit) {
                    break;
                }
            }
        } else {
            $q = $this->buildSuggestionsQuery('draft', $search, $ptFilter, $sourceFilter, $limit, 1, $postIdFilter, true);
            $ids = is_array($q->posts) ? array_map('intval', $q->posts) : [];
            $ids = array_values(array_filter($ids, static fn ($v) => $v > 0));
        }

        $sessionId = wp_generate_uuid4();
        $key = $this->applySessionKey($sessionId);

        set_transient($key, [
            'ids' => $ids,
            'total' => count($ids),
            'processed' => 0,
            'applied' => 0,
            'apply_only_changed' => $applyOnlyChanged ? 1 : 0,
            'filters' => [
                'search' => $search,
                'pt' => $ptFilter,
                'source' => $sourceFilter,
                'post_id' => $postIdFilter,
                'limit' => $limit,
                'ids' => $idsStr,
            ],
            'created_at' => time(),
        ], HOUR_IN_SECONDS);

        wp_send_json_success([
            'session' => $sessionId,
            'total' => count($ids),
        ]);
    }

    /**
     * AJAX: Apply next chunk
     */
    public function ajaxApplyAllStep(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_send_json_error(['message' => __('You do not have permission.', 'wp-seo-next')], 403);
        }
        check_ajax_referer(self::AJAX_NONCE_ACTION, 'nonce');

        $sessionId = isset($_POST['session']) ? sanitize_text_field((string) wp_unslash($_POST['session'])) : '';
        if ($sessionId === '') {
            wp_send_json_error(['message' => __('Missing session.', 'wp-seo-next')], 400);
        }

        $chunk = isset($_POST['chunk']) ? (int) $_POST['chunk'] : 10;
        $chunk = max(1, min(50, $chunk));

        $key = $this->applySessionKey($sessionId);
        $state = get_transient($key);
        if (!is_array($state) || !isset($state['ids']) || !is_array($state['ids'])) {
            wp_send_json_error(['message' => __('Session expired or invalid.', 'wp-seo-next')], 400);
        }

        $ids = array_values(array_map('intval', $state['ids']));
        $total = isset($state['total']) ? (int) $state['total'] : count($ids);
        $processed = isset($state['processed']) ? (int) $state['processed'] : 0;
        $applied = isset($state['applied']) ? (int) $state['applied'] : 0;
        $applyOnlyChanged = !isset($state['apply_only_changed']) || (bool) $state['apply_only_changed'];

        if (empty($ids)) {
            delete_transient($key);
            wp_send_json_success([
                'done' => true,
                'processed' => $processed,
                'total' => $total,
                'applied' => $applied,
            ]);
        }

        $batch = array_splice($ids, 0, $chunk);

        $jobs = new \WPSN\Modules\Bulk\BulkJobs($this->settings, $this->logger);

        foreach ($batch as $postId) {
            $before = (string) get_post_meta((int) $postId, BulkJobs::META_STATUS, true);
            $jobs->handleApply(null, ['post_id' => (int) $postId, 'apply_only_changed' => $applyOnlyChanged ? 1 : 0], []);
            $after = (string) get_post_meta((int) $postId, BulkJobs::META_STATUS, true);

            $processed++;
            if ($before !== 'applied' && $after === 'applied') {
                $applied++;
            }
        }

        $state['ids'] = $ids;
        $state['processed'] = $processed;
        $state['applied'] = $applied;

        set_transient($key, $state, HOUR_IN_SECONDS);

        $done = empty($ids);

        if ($done) {
            /**
             * Fired when AJAX bulk apply-all finishes.
             *
             * @param int $processed
             * @param int $applied
             * @param array<string,mixed> $meta
             */
            $filters = isset($state['filters']) && is_array($state['filters']) ? $state['filters'] : [];
            do_action('wpsn/bulk/apply_all_finished', (int) $processed, (int) $applied, [
                'source' => 'ajax',
                'apply_only_changed' => $applyOnlyChanged ? 1 : 0,
                'filters' => $filters,
            ]);

            delete_transient($key);
        }

        wp_send_json_success([
            'done' => $done,
            'processed' => $processed,
            'total' => $total,
            'applied' => $applied,
            'remaining' => count($ids),
        ]);
    }

    public function ajaxApplyAllCancel(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_send_json_error(['message' => __('You do not have permission.', 'wp-seo-next')], 403);
        }
        check_ajax_referer(self::AJAX_NONCE_ACTION, 'nonce');

        $sessionId = isset($_POST['session']) ? sanitize_text_field((string) wp_unslash($_POST['session'])) : '';
        if ($sessionId !== '') {
            delete_transient($this->applySessionKey($sessionId));
        }

        wp_send_json_success(['ok' => true]);
    }


    /**
     * AJAX: Start generate-drafts session (Local/OpenAI) with progress.
     */
    public function ajaxGenerateStart(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_send_json_error(['message' => __('You do not have permission.', 'wp-seo-next')], 403);
        }
        check_ajax_referer(self::AJAX_NONCE_ACTION, 'nonce');

        $source = isset($_POST['source']) ? sanitize_key((string) wp_unslash($_POST['source'])) : 'local';
        if (!in_array($source, ['local', 'openai'], true)) {
            $source = 'local';
        }

        if ($source === 'openai' && !Capabilities::canUseAI()) {
            wp_send_json_error(['message' => __('You do not have permission to use OpenAI generation.', 'wp-seo-next')], 403);
        }

        // Pre-check settings to avoid starting a run that will instantly fail.
        if ($source === 'local' && !(bool) $this->settings->get('ai.local.enabled', true)) {
            wp_send_json_error(['message' => __('Local suggestions are disabled in Settings.', 'wp-seo-next')], 400);
        }

        if ($source === 'openai') {
            $secrets = new AISecretsRepository();
            if (!(bool) $this->settings->get('ai.external.enabled', false)) {
                wp_send_json_error(['message' => __('OpenAI generation is disabled in Settings.', 'wp-seo-next')], 400);
            }
            if (!$secrets->hasOpenAIKey()) {
                wp_send_json_error(['message' => __('OpenAI API key is not configured.', 'wp-seo-next')], 400);
            }
        }

        $fieldsStr = isset($_POST['fields']) ? sanitize_text_field((string) wp_unslash($_POST['fields'])) : '';
        $fields = array_filter(array_map('sanitize_key', explode(',', $fieldsStr)));
        $fields = array_values(array_intersect(['title', 'description'], $fields));
        if (empty($fields)) {
            $fields = ['title', 'description'];
        }

        $typesStr = isset($_POST['post_types']) ? sanitize_text_field((string) wp_unslash($_POST['post_types'])) : '';
        $postTypes = array_filter(array_map('sanitize_key', explode(',', $typesStr)));
        $postTypes = array_values(array_filter($postTypes, static fn ($pt) => $pt !== '' && post_type_exists($pt)));
        if (empty($postTypes)) {
            $postTypes = ['post', 'page'];
        }

        $onlyIfEmpty = !empty($_POST['only_if_empty']);
        $overwrite = !empty($_POST['overwrite_suggestions']);

        $backoffSeconds = isset($_POST['backoff_seconds']) ? (int) $_POST['backoff_seconds'] : 10;
        $backoffSeconds = max(1, min(120, $backoffSeconds));

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

        $singlePostId = isset($_POST['post_id']) ? (int) $_POST['post_id'] : 0;

        if (!empty($idsFromRequest)) {
            $ids = $idsFromRequest;
        } elseif ($singlePostId > 0) {
            $p = get_post($singlePostId);
            if (!$p || $p->post_type === 'revision') {
                wp_send_json_error(['message' => __('Invalid post_id filter.', 'wp-seo-next')], 400);
            }
            $ids = [(int) $singlePostId];
        } else {
            global $wpdb;
            $typesPlaceholders = implode(',', array_fill(0, count($postTypes), '%s'));
            $params = $postTypes;
            $params[] = $limit;

            // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
            $sql = "SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish' AND post_type IN ({$typesPlaceholders}) ORDER BY ID DESC LIMIT %d";
            $ids = $wpdb->get_col($wpdb->prepare($sql, $params));

            $ids = is_array($ids) ? array_values(array_filter(array_map('intval', $ids), static fn ($v) => $v > 0)) : [];
        }

        $sessionId = wp_generate_uuid4();
        $key = $this->generateSessionKey($sessionId);

        set_transient($key, [
            'ids' => $ids,
            'total' => count($ids),
            'processed' => 0,
            'generated' => 0,
            'skipped' => 0,
            'failed' => 0,
            'errors' => [],
            'backoff_current' => $backoffSeconds,
            'params' => [
                'source' => $source,
                'fields' => $fields,
                'only_if_empty' => $onlyIfEmpty,
                'overwrite_suggestions' => $overwrite,
                'backoff_seconds' => $backoffSeconds,
            ],
            'created_at' => time(),
        ], HOUR_IN_SECONDS);

        wp_send_json_success([
            'session' => $sessionId,
            'total' => count($ids),
        ]);
    }

    /**
     * AJAX: Generate next chunk of drafts
     */
    public function ajaxGenerateStep(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_send_json_error(['message' => __('You do not have permission.', 'wp-seo-next')], 403);
        }
        check_ajax_referer(self::AJAX_NONCE_ACTION, 'nonce');

        $sessionId = isset($_POST['session']) ? sanitize_text_field((string) wp_unslash($_POST['session'])) : '';
        if ($sessionId === '') {
            wp_send_json_error(['message' => __('Missing session.', 'wp-seo-next')], 400);
        }

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

        $key = $this->generateSessionKey($sessionId);
        $state = get_transient($key);
        if (!is_array($state) || !isset($state['ids']) || !is_array($state['ids']) || !isset($state['params']) || !is_array($state['params'])) {
            wp_send_json_error(['message' => __('Session expired or invalid.', 'wp-seo-next')], 400);
        }

        $ids = array_values(array_map('intval', $state['ids']));
        $total = isset($state['total']) ? (int) $state['total'] : count($ids);

        $processed = isset($state['processed']) ? (int) $state['processed'] : 0;
        $generated = isset($state['generated']) ? (int) $state['generated'] : 0;
        $skipped = isset($state['skipped']) ? (int) $state['skipped'] : 0;
        $failed = isset($state['failed']) ? (int) $state['failed'] : 0;
        $errors = isset($state['errors']) && is_array($state['errors']) ? $state['errors'] : [];

        $params = $state['params'];
        $source = isset($params['source']) ? sanitize_key((string) $params['source']) : 'local';
        $fields = isset($params['fields']) && is_array($params['fields']) ? array_values($params['fields']) : ['title', 'description'];
        $onlyIfEmpty = !empty($params['only_if_empty']);
        $overwrite = !empty($params['overwrite_suggestions']);

        // AJAX OpenAI rate limit backoff (HTTP 429): keep stateful delay across steps.
        $backoffBase = isset($params['backoff_seconds']) ? (int) $params['backoff_seconds'] : 10;
        $backoffBase = max(1, min(120, $backoffBase));
        $backoffCurrent = isset($state['backoff_current']) ? (int) $state['backoff_current'] : $backoffBase;
        $backoffCurrent = max($backoffBase, min(120, $backoffCurrent));

        if (empty($ids)) {
            delete_transient($key);
            wp_send_json_success([
                'done' => true,
                'processed' => $processed,
                'total' => $total,
                'generated' => $generated,
                'skipped' => $skipped,
                'failed' => $failed,
                'errors' => $errors,
            ]);
        }

        $batch = array_splice($ids, 0, $chunk);

        $jobs = new \WPSN\Modules\Bulk\BulkJobs($this->settings, $this->logger);

        $rateLimited = false;
        $retryAfter = 0;
        $rateLimitMessage = '';

        for ($i = 0, $n = count($batch); $i < $n; $i++) {
            $postId = (int) $batch[$i];
            if ($postId <= 0) {
                continue;
            }

            $beforeUpdated = (int) get_post_meta($postId, BulkJobs::META_UPDATED, true);

            $res = $jobs->handleGenerate(null, [
                'post_id' => $postId,
                'user_id' => (int) get_current_user_id(),
                'source' => $source,
                'fields' => $fields,
                'only_if_empty' => $onlyIfEmpty,
                'overwrite_suggestions' => $overwrite,
                'backoff_seconds' => $backoffCurrent,
            ], []);

            $status = isset($res['status']) ? (string) $res['status'] : 'done';

            // Soft rate-limit handling (OpenAI 429): pause and retry later without counting as failure.
            if ($status === 'retry' && !empty($res['rate_limited'])) {
                $rateLimited = true;
                $retryAfter = isset($res['retry_after']) ? (int) $res['retry_after'] : 0;
                $retryAfter = max($backoffCurrent, $retryAfter);
                $retryAfter = max(1, min(120, $retryAfter));
                $rateLimitMessage = isset($res['error']) ? sanitize_text_field((string) $res['error']) : __('Rate limited.', 'wp-seo-next');

                // Put current + remaining posts back to the front of the queue for this session.
                $remaining = array_slice($batch, $i);
                $remaining = array_values(array_filter(array_map('intval', $remaining), static fn ($v) => $v > 0));
                $ids = array_merge($remaining, $ids);

                // Increase backoff gradually for the next retry.
                $state['backoff_current'] = min(120, max($backoffBase, $retryAfter * 2));
                break;
            }

            $afterUpdated = (int) get_post_meta($postId, BulkJobs::META_UPDATED, true);
            $processed++;

            if ($status === 'done') {
                if ($afterUpdated > $beforeUpdated) {
                    $generated++;
                } else {
                    $skipped++;
                }
                continue;
            }

            // Treat other retry/failure as a failure for AJAX runs (user can rerun later).
            $failed++;
            $msg = '';
            if (isset($res['error'])) {
                $msg = sanitize_text_field((string) $res['error']);
            } elseif ($status !== '') {
                $msg = sanitize_text_field($status);
            }
            if ($msg === '') {
                $msg = __('Unknown error', 'wp-seo-next');
            }

            if (!isset($errors[$msg])) {
                // Limit stored unique errors to keep transient small.
                if (count($errors) < 8) {
                    $errors[$msg] = 1;
                } else {
                    $errors['Other'] = isset($errors['Other']) ? ((int) $errors['Other'] + 1) : 1;
                }
            } else {
                $errors[$msg] = (int) $errors[$msg] + 1;
            }
        }

        if (!$rateLimited) {
            // Reset backoff when we successfully process a chunk without hitting 429.
            $state['backoff_current'] = $backoffBase;
        }

        $state['ids'] = $ids;
        $state['processed'] = $processed;
        $state['generated'] = $generated;
        $state['skipped'] = $skipped;
        $state['failed'] = $failed;
        $state['errors'] = $errors;

        set_transient($key, $state, HOUR_IN_SECONDS);

        $done = empty($ids);

        if ($done) {
            delete_transient($key);
        }

        wp_send_json_success([
            'done' => $done,
            'processed' => $processed,
            'total' => $total,
            'generated' => $generated,
            'skipped' => $skipped,
            'failed' => $failed,
            'remaining' => count($ids),
            'errors' => $errors,
            'rate_limited' => $rateLimited,
            'retry_after' => $retryAfter,
            'rate_limit_message' => $rateLimitMessage,
        ]);
    }

    public function ajaxGenerateCancel(): void
    {
        if (!Capabilities::canManageTools()) {
            wp_send_json_error(['message' => __('You do not have permission.', 'wp-seo-next')], 403);
        }
        check_ajax_referer(self::AJAX_NONCE_ACTION, 'nonce');

        $sessionId = isset($_POST['session']) ? sanitize_text_field((string) wp_unslash($_POST['session'])) : '';
        if ($sessionId !== '') {
            delete_transient($this->generateSessionKey($sessionId));
        }

        wp_send_json_success(['ok' => true]);
    }


    private function applySessionKey(string $sessionId): string
    {
        $uid = (int) get_current_user_id();
        return 'wpsn_bulk_apply_' . $uid . '_' . preg_replace('/[^a-zA-Z0-9\-]/', '', $sessionId);
    }

    private function generateSessionKey(string $sessionId): string
    {
        $uid = (int) get_current_user_id();
        return 'wpsn_bulk_generate_' . $uid . '_' . preg_replace('/[^a-zA-Z0-9\-]/', '', $sessionId);
    }

    private function countSuggestionsByStatus(string $status): int
    {
        global $wpdb;
        $pm = $wpdb->postmeta;

        $count = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT COUNT(*) FROM {$pm} WHERE meta_key = %s AND meta_value = %s",
                BulkJobs::META_STATUS,
                $status
            )
        );

        return (int) $count;
    }


    /**
     * Failed jobs: filters (job_type + per page).
     */
    private function renderFailedJobsFilters(string $jobTypeFilter, int $perPage): void
    {
        $resetUrl = $this->pageUrl(['view' => 'failed']);

        $types = $this->getFailedJobTypes();

        ?>
        <form method="get" action="<?php echo esc_url(admin_url('tools.php')); ?>" style="margin: 10px 0; max-width: 1200px;">
            <input type="hidden" name="page" value="wpsn-seo-bulk" />
            <input type="hidden" name="view" value="failed" />

            <p class="search-box" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
                <select name="job_type" id="wpsn-filter-job-type">
                    <option value=""><?php echo esc_html__('All job types', 'wp-seo-next'); ?></option>
                    <?php foreach ($types as $t) : ?>
                        <option value="<?php echo esc_attr($t); ?>" <?php selected($jobTypeFilter, $t); ?>><?php echo esc_html($t); ?></option>
                    <?php endforeach; ?>
                </select>

                <select name="per_page">
                    <?php foreach ([20, 50, 100, 200] as $n) : ?>
                        <option value="<?php echo (int) $n; ?>" <?php selected($perPage, $n); ?>><?php echo esc_html(sprintf(__('%d / page', 'wp-seo-next'), (int) $n)); ?></option>
                    <?php endforeach; ?>
                </select>

                <input type="submit" class="button" value="<?php echo esc_attr__('Filter', 'wp-seo-next'); ?>" />
                <a class="button button-secondary" href="<?php echo esc_url($resetUrl); ?>"><?php echo esc_html__('Reset', 'wp-seo-next'); ?></a>
            </p>
        </form>
        <?php
    }

    /**
     * Failed jobs: table + pagination.
     */
    private function renderFailedJobsTable(string $jobTypeFilter, int $perPage, int $paged): void
    {
        $perPage = max(10, min(200, $perPage));
        $paged = max(1, $paged);
        $offset = ($paged - 1) * $perPage;

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

        $where = "status = %s";
        $params = ['failed'];
        if ($jobTypeFilter !== '') {
            $where .= " AND job_type = %s";
            $params[] = $jobTypeFilter;
        }

        // Count.
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $total = (int) $wpdb->get_var($wpdb->prepare("SELECT COUNT(*) FROM {$table} WHERE {$where}", $params));

        // Rows.
        $paramsRows = $params;
        $paramsRows[] = $perPage;
        $paramsRows[] = $offset;

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, job_type, attempts, run_at, updated_at, last_error, payload
                 FROM {$table}
                 WHERE {$where}
                 ORDER BY updated_at DESC, id DESC
                 LIMIT %d OFFSET %d",
                $paramsRows
            ),
            ARRAY_A
        );

        if ($total <= 0) {
            echo '<p class="description">' . esc_html__('No failed jobs found.', 'wp-seo-next') . '</p>';
            return;
        }

        $totalPages = (int) ceil($total / $perPage);

        echo '<table class="widefat striped" style="max-width: 1200px;">';
        echo '<thead><tr>';
        echo '<th style="width:70px;">' . esc_html__('ID', 'wp-seo-next') . '</th>';
        echo '<th style="width:160px;">' . esc_html__('Job type', 'wp-seo-next') . '</th>';
        echo '<th style="width:90px;">' . esc_html__('Attempts', 'wp-seo-next') . '</th>';
        echo '<th style="width:170px;">' . esc_html__('Updated', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Target', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Last error', 'wp-seo-next') . '</th>';
        echo '</tr></thead>';
        echo '<tbody>';

        if (empty($rows)) {
            echo '<tr><td colspan="6">' . esc_html__('No failed jobs found.', 'wp-seo-next') . '</td></tr>';
        } else {
            foreach ($rows as $r) {
                $id = isset($r['id']) ? (int) $r['id'] : 0;
                $jobType = isset($r['job_type']) ? (string) $r['job_type'] : '';
                $attempts = isset($r['attempts']) ? (int) $r['attempts'] : 0;
                $updatedAt = isset($r['updated_at']) ? (string) $r['updated_at'] : '';
                $payloadRaw = isset($r['payload']) ? (string) $r['payload'] : '';
                $payload = json_decode($payloadRaw, true);
                $postId = is_array($payload) && isset($payload['post_id']) ? (int) $payload['post_id'] : 0;

                $targetHtml = '';
                if ($postId > 0) {
                    $title = get_the_title($postId);
                    $editUrl = get_edit_post_link($postId, 'raw');
                    $viewUrl = get_permalink($postId);

                    if ($editUrl) {
                        $targetHtml .= sprintf(
                            '<a href="%s">%s</a>',
                            esc_url($editUrl),
                            esc_html($title !== '' ? $title : ('#' . $postId))
                        );
                    } else {
                        $targetHtml .= esc_html($title !== '' ? $title : ('#' . $postId));
                    }

                    if ($viewUrl) {
                        $targetHtml .= ' &nbsp; ' . sprintf(
                            '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
                            esc_url($viewUrl),
                            esc_html__('View', 'wp-seo-next')
                        );
                    }
                } else {
                    $targetHtml = '<span class="description">' . esc_html__('(no post_id in payload)', 'wp-seo-next') . '</span>';
                }

                $err = isset($r['last_error']) ? (string) $r['last_error'] : '';
                $err = trim(preg_replace('/\s+/', ' ', $err));
                $errShort = $err;
                $max = 180;
                if ($errShort !== '') {
                    if (function_exists('mb_strlen') && function_exists('mb_substr')) {
                        if (mb_strlen($errShort) > $max) {
                            $errShort = (string) mb_substr($errShort, 0, $max) . '…';
                        }
                    } elseif (strlen($errShort) > $max) {
                        $errShort = substr($errShort, 0, $max) . '…';
                    }
                }

                echo '<tr>';
                echo '<td>' . (int) $id . '</td>';
                echo '<td><code>' . esc_html($jobType) . '</code></td>';
                echo '<td>' . (int) $attempts . '</td>';
                echo '<td>' . esc_html($updatedAt) . '</td>';
                // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                echo '<td>' . $targetHtml . '</td>';
                echo '<td>';
                if ($err === '') {
                    echo '<span class="description">' . esc_html__('(empty)', 'wp-seo-next') . '</span>';
                } else {
                    echo '<code title="' . esc_attr($err) . '">' . esc_html($errShort) . '</code>';
                }
                echo '</td>';
                echo '</tr>';
            }
        }

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

        // Pagination.
        if ($totalPages > 1) {
            $baseUrl = $this->pageUrl([
                'view' => 'failed',
                'job_type' => $jobTypeFilter,
                'per_page' => $perPage,
            ]);

            $links = paginate_links([
                'base' => add_query_arg('paged', '%#%', $baseUrl),
                'format' => '',
                'current' => $paged,
                'total' => $totalPages,
                'prev_text' => '«',
                'next_text' => '»',
                'type' => 'array',
            ]);

            if (is_array($links) && !empty($links)) {
                echo '<div class="tablenav" style="max-width:1200px;"><div class="tablenav-pages" style="margin: 8px 0;">';
                foreach ($links as $link) {
                    // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
                    echo $link . ' ';
                }
                echo '</div></div>';
            }
        }
    }

    /**
     * @return list<string>
     */
    private function getFailedJobTypes(): array
    {
        global $wpdb;
        $table = $wpdb->prefix . 'wpsn_queue_jobs';

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $types = $wpdb->get_col(
            $wpdb->prepare(
                "SELECT DISTINCT job_type FROM {$table} WHERE status = %s ORDER BY job_type ASC",
                'failed'
            )
        );

        $types = is_array($types) ? array_values(array_filter(array_map('sanitize_key', $types), static fn ($t) => $t !== '')) : [];

        return $types;
    }


    /**
     * Render a list of failed queue jobs with last_error (for debugging).
     *
     * @param array<string,mixed> $counts
     */
    private function renderFailedJobsSection(array $counts): void
    {
        $failedTotal = isset($counts['failed']) ? (int) $counts['failed'] : 0;
        if ($failedTotal <= 0) {
            return;
        }

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

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

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        $rows = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT id, job_type, attempts, run_at, updated_at, last_error, payload 
                 FROM {$table}
                 WHERE status = %s
                 ORDER BY updated_at DESC, id DESC
                 LIMIT %d OFFSET %d",
                'failed',
                $perPage,
                $offset
            ),
            ARRAY_A
        );

        $totalPages = (int) ceil($failedTotal / $perPage);

        $open = $failedTotal <= 5 ? ' open' : '';

        ?>
        <details<?php echo $open; ?> style="max-width: 1200px; margin: 12px 0 0 0;">
            <summary style="cursor:pointer; font-weight: 600;">
                <?php echo esc_html(sprintf(__('Failed jobs (%d)', 'wp-seo-next'), $failedTotal)); ?>
            </summary>

            <p class="description" style="margin-top: 8px;">
                <?php echo esc_html__('These jobs failed in the background worker. The “last error” helps you understand why (rate limits, timeouts, missing settings, etc.).', 'wp-seo-next'); ?>
            </p>

            <table class="widefat striped" style="max-width: 1200px;">
                <thead>
                    <tr>
                        <th style="width:70px;"><?php echo esc_html__('ID', 'wp-seo-next'); ?></th>
                        <th style="width:140px;"><?php echo esc_html__('Job type', 'wp-seo-next'); ?></th>
                        <th style="width:90px;"><?php echo esc_html__('Attempts', 'wp-seo-next'); ?></th>
                        <th style="width:170px;"><?php echo esc_html__('Updated', 'wp-seo-next'); ?></th>
                        <th><?php echo esc_html__('Target', 'wp-seo-next'); ?></th>
                        <th><?php echo esc_html__('Last error', 'wp-seo-next'); ?></th>
                    </tr>
                </thead>
                <tbody>
                    <?php if (empty($rows)) : ?>
                        <tr><td colspan="6"><?php echo esc_html__('No failed jobs found.', 'wp-seo-next'); ?></td></tr>
                    <?php else : ?>
                        <?php foreach ($rows as $r) : ?>
                            <?php
                                $id = isset($r['id']) ? (int) $r['id'] : 0;
                                $jobType = isset($r['job_type']) ? (string) $r['job_type'] : '';
                                $attempts = isset($r['attempts']) ? (int) $r['attempts'] : 0;
                                $updatedAt = isset($r['updated_at']) ? (string) $r['updated_at'] : '';
                                $payloadRaw = isset($r['payload']) ? (string) $r['payload'] : '';
                                $payload = json_decode($payloadRaw, true);
                                $postId = is_array($payload) && isset($payload['post_id']) ? (int) $payload['post_id'] : 0;

                                $targetHtml = '';
                                if ($postId > 0) {
                                    $title = get_the_title($postId);
                                    $editUrl = get_edit_post_link($postId, 'raw');
                                    $viewUrl = get_permalink($postId);

                                    if ($editUrl) {
                                        $targetHtml .= sprintf(
                                            '<a href="%s">%s</a>',
                                            esc_url($editUrl),
                                            esc_html($title !== '' ? $title : ('#' . $postId))
                                        );
                                    } else {
                                        $targetHtml .= esc_html($title !== '' ? $title : ('#' . $postId));
                                    }

                                    if ($viewUrl) {
                                        $targetHtml .= ' &nbsp; ' . sprintf(
                                            '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>',
                                            esc_url($viewUrl),
                                            esc_html__('View', 'wp-seo-next')
                                        );
                                    }
                                } else {
                                    $targetHtml = '<span class="description">' . esc_html__('(no post_id in payload)', 'wp-seo-next') . '</span>';
                                }

                                $err = isset($r['last_error']) ? (string) $r['last_error'] : '';
                                $err = trim(preg_replace('/\s+/', ' ', $err));
                                $errShort = $err;
                                $max = 180;
                                if ($errShort !== '') {
                                    if (function_exists('mb_strlen') && function_exists('mb_substr')) {
                                        if (mb_strlen($errShort) > $max) {
                                            $errShort = (string) mb_substr($errShort, 0, $max) . '…';
                                        }
                                    } elseif (strlen($errShort) > $max) {
                                        $errShort = substr($errShort, 0, $max) . '…';
                                    }
                                }
                            ?>
                            <tr>
                                <td><?php echo (int) $id; ?></td>
                                <td><code><?php echo esc_html($jobType); ?></code></td>
                                <td><?php echo (int) $attempts; ?></td>
                                <td><?php echo esc_html($updatedAt); ?></td>
                                <td><?php echo $targetHtml; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
                                <td>
                                    <?php if ($err === '') : ?>
                                        <span class="description"><?php echo esc_html__('(empty)', 'wp-seo-next'); ?></span>
                                    <?php else : ?>
                                        <code title="<?php echo esc_attr($err); ?>"><?php echo esc_html($errShort); ?></code>
                                    <?php endif; ?>
                                </td>
                            </tr>
                        <?php endforeach; ?>
                    <?php endif; ?>
                </tbody>
            </table>

            <?php if ($totalPages > 1) : ?>
                <?php
                    $baseArgs = [];
                    $baseArgs['view'] = isset($_GET['view']) ? sanitize_key((string) wp_unslash($_GET['view'])) : 'draft';
                    $baseArgs['s'] = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';
                    $baseArgs['pt'] = isset($_GET['pt']) ? sanitize_key((string) wp_unslash($_GET['pt'])) : 'any';
                    $baseArgs['source'] = isset($_GET['source']) ? sanitize_key((string) wp_unslash($_GET['source'])) : '';
                    $baseArgs['per_page'] = isset($_GET['per_page']) ? (int) $_GET['per_page'] : 50;
                    $baseArgs['paged'] = isset($_GET['paged']) ? (int) $_GET['paged'] : 1;

                    $baseUrl = $this->pageUrl($baseArgs);
                    $baseUrl = remove_query_arg('failed_paged', $baseUrl);
                    $baseUrl = add_query_arg('failed_paged', '%#%', $baseUrl);

                    $links = paginate_links([
                        'base' => $baseUrl,
                        'format' => '',
                        'current' => $paged,
                        'total' => $totalPages,
                        'type' => 'array',
                        'prev_text' => '‹',
                        'next_text' => '›',
                    ]);
                ?>
                <?php if (is_array($links)) : ?>
                    <div class="tablenav" style="margin: 10px 0;">
                        <div class="tablenav-pages" style="margin:0;">
                            <?php foreach ($links as $l) : ?>
                                <?php echo $l; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
                            <?php endforeach; ?>
                        </div>
                    </div>
                <?php endif; ?>
            <?php endif; ?>
        </details>
        <?php
    }

    private function buildSuggestionsQuery(string $status, string $search, string $ptFilter, string $sourceFilter, int $perPage, int $paged, int $postIdFilter = 0, bool $idsOnly = false): \WP_Query
    {
        $metaQuery = [
            [
                'key' => BulkJobs::META_STATUS,
                'value' => $status,
                'compare' => '=',
            ],
        ];

        if ($sourceFilter !== '') {
            $metaQuery[] = [
                'key' => BulkJobs::META_SOURCE,
                'value' => $sourceFilter,
                'compare' => '=',
            ];
        }

        $args = [
            'post_type' => ($ptFilter === 'any' || $ptFilter === '') ? 'any' : $ptFilter,
            'post_status' => ['publish', 'private', 'draft', 'pending', 'future'],
            'posts_per_page' => $perPage,
            'paged' => $paged,
            'orderby' => 'ID',
            'order' => 'DESC',
            'meta_query' => $metaQuery,
        ];

        if (trim($search) !== '') {
            $args['s'] = $search;
        }

        if ($postIdFilter > 0) {
            $args['p'] = $postIdFilter;
            $args['posts_per_page'] = 1;
            $args['paged'] = 1;
            unset($args['s']);
        }

        if ($idsOnly) {
            $args['fields'] = 'ids';
            $args['no_found_rows'] = true;
            $args['paged'] = 1;
        }

        return new \WP_Query($args);
    }

    private function renderSuggestionsFilters(string $view, string $search, string $ptFilter, string $sourceFilter, int $perPage, int $postIdFilter = 0): void
    {
        $resetUrl = $this->pageUrl(['view' => $view]);

        $postTypes = get_post_types(['public' => true], 'objects');
        unset($postTypes['attachment']);

        ?>
        <form method="get" action="<?php echo esc_url(admin_url('tools.php')); ?>" style="margin: 10px 0; max-width: 1200px;">
            <input type="hidden" name="page" value="wpsn-seo-bulk" />
            <input type="hidden" name="view" value="<?php echo esc_attr($view); ?>" />
            <?php if ($postIdFilter > 0) : ?>
                <input type="hidden" name="post_id" value="<?php echo (int) $postIdFilter; ?>" />
            <?php endif; ?>

            <p class="search-box" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
                <label class="screen-reader-text" for="wpsn-suggestions-search"><?php echo esc_html__('Search posts', 'wp-seo-next'); ?></label>
                <input type="search" id="wpsn-suggestions-search" name="s" value="<?php echo esc_attr($search); ?>" placeholder="<?php echo esc_attr__('Search post title…', 'wp-seo-next'); ?>" />

                <select name="pt" id="wpsn-filter-post-type">
                    <option value="any"><?php echo esc_html__('All post types', 'wp-seo-next'); ?></option>
                    <?php foreach ($postTypes as $slug => $pt) : ?>
                        <option value="<?php echo esc_attr($slug); ?>" <?php selected($ptFilter, $slug); ?>><?php echo esc_html($pt->labels->singular_name ?? $slug); ?></option>
                    <?php endforeach; ?>
                </select>

                <select name="source" id="wpsn-filter-source">
                    <option value=""><?php echo esc_html__('All sources', 'wp-seo-next'); ?></option>
                    <option value="local" <?php selected($sourceFilter, 'local'); ?>><?php echo esc_html__('Local', 'wp-seo-next'); ?></option>
                    <option value="openai" <?php selected($sourceFilter, 'openai'); ?>><?php echo esc_html__('OpenAI', 'wp-seo-next'); ?></option>
                </select>

                <select name="per_page">
                    <?php foreach ([20, 50, 100, 200] as $n) : ?>
                        <option value="<?php echo (int) $n; ?>" <?php selected($perPage, $n); ?>><?php echo esc_html(sprintf(__('%d / page', 'wp-seo-next'), (int) $n)); ?></option>
                    <?php endforeach; ?>
                </select>

                <input type="submit" class="button" value="<?php echo esc_attr__('Filter', 'wp-seo-next'); ?>" />
                <a class="button button-secondary" href="<?php echo esc_url($resetUrl); ?>"><?php echo esc_html__('Reset', 'wp-seo-next'); ?></a>

                <?php if ($postIdFilter > 0) : ?>
                    <span class="description" style="margin-left:6px;">
                        <?php echo esc_html(sprintf(__('Filtered to post #%d', 'wp-seo-next'), (int) $postIdFilter)); ?>
                    </span>
                <?php endif; ?>
            </p>
        </form>
        <?php
    }

    private function renderPagination(\WP_Query $q, string $view, string $search, string $ptFilter, string $sourceFilter, int $perPage, int $paged, int $postIdFilter = 0): void
    {
        $totalPages = (int) $q->max_num_pages;
        if ($totalPages <= 1) {
            return;
        }

        $baseArgs = [
            'view' => $view,
            's' => $search,
            'pt' => $ptFilter,
            'source' => $sourceFilter,
            'per_page' => $perPage,
        ];

        if ($postIdFilter > 0) {
            $baseArgs['post_id'] = $postIdFilter;
        }

        $baseUrl = $this->pageUrl($baseArgs);

        $links = paginate_links([
            'base' => add_query_arg('paged', '%#%', $baseUrl),
            'format' => '',
            'current' => $paged,
            'total' => $totalPages,
            'prev_text' => '«',
            'next_text' => '»',
            'type' => 'array',
        ]);

        if (!is_array($links) || empty($links)) {
            return;
        }

        echo '<div class="tablenav" style="max-width:1200px;"><div class="tablenav-pages" style="margin: 8px 0;">';
        foreach ($links as $link) {
            // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
            echo $link . " ";
        }
        echo '</div></div>';
    }

    private function renderSuggestionsTable(\WP_Query $q, string $status): void
    {
        if (!$q->have_posts()) {
            echo '<p class="description">' . esc_html__('No items found.', 'wp-seo-next') . '</p>';
            return;
        }

        echo '<table class="widefat striped" style="max-width: 1200px;">';
        echo '<thead><tr>';
        echo '<th style="width:70px;">' . esc_html__('ID', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Post', 'wp-seo-next') . '</th>';
        echo '<th style="width:90px;">' . esc_html__('Source', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Suggested title', 'wp-seo-next') . '</th>';
        echo '<th>' . esc_html__('Suggested description', 'wp-seo-next') . '</th>';
        echo '<th style="width:110px;">' . esc_html__('Updated', 'wp-seo-next') . '</th>';
        echo '<th style="width:230px;">' . esc_html__('Actions', 'wp-seo-next') . '</th>';
        echo '</tr></thead><tbody>';

        while ($q->have_posts()) {
            $q->the_post();
            $postId = (int) get_the_ID();
            $title = get_the_title();

            $raw = (string) get_post_meta($postId, BulkJobs::META_SUGGESTIONS, true);
            $data = $raw !== '' ? json_decode($raw, true) : null;
            $source = '';
            $sTitle = '';
            $sDesc = '';
            if (is_array($data)) {
                $source = isset($data['source']) ? (string) $data['source'] : '';
                if (isset($data['fields']['title']['text'])) {
                    $sTitle = (string) $data['fields']['title']['text'];
                }
                if (isset($data['fields']['description']['text'])) {
                    $sDesc = (string) $data['fields']['description']['text'];
                }
            }

            $updatedTs = (int) get_post_meta($postId, BulkJobs::META_UPDATED, true);
            $updatedHuman = $updatedTs > 0 ? human_time_diff($updatedTs, time()) . ' ' . __('ago', 'wp-seo-next') : '-';

            $editUrl = get_edit_post_link($postId, 'raw');
            $viewUrl = get_permalink($postId);

            echo '<tr>';
            echo '<td>' . (int) $postId . '</td>';
            echo '<td><strong>' . esc_html($title) . '</strong><br/><span class="description">' . esc_html($viewUrl) . '</span></td>';
            echo '<td>' . esc_html($source !== '' ? $source : '-') . '</td>';
            echo '<td>' . esc_html($this->truncateUi($sTitle, 90)) . '</td>';
            echo '<td>' . esc_html($this->truncateUi($sDesc, 120)) . '</td>';
            echo '<td>' . esc_html($updatedHuman) . '</td>';

            echo '<td>';
            if ($editUrl) {
                echo '<a class="button button-small" href="' . esc_url($editUrl) . '">' . esc_html__('Edit', 'wp-seo-next') . '</a> ';
            }
            if ($viewUrl) {
                echo '<a class="button button-small" target="_blank" rel="noopener" href="' . esc_url($viewUrl) . '">' . esc_html__('View', 'wp-seo-next') . '</a> ';
            }

            if ($status === 'draft') {
                $applyUrl = wp_nonce_url(
                    add_query_arg(['action' => 'wpsn_bulk_apply_single', 'post_id' => $postId], admin_url('admin-post.php')),
                    'wpsn_bulk_apply_' . $postId,
                    'wpsn_nonce'
                );
                echo '<a class="button button-primary button-small" href="' . esc_url($applyUrl) . '">' . esc_html__('Apply', 'wp-seo-next') . '</a>';
            } else {
                $undoUrl = wp_nonce_url(
                    add_query_arg(['action' => 'wpsn_bulk_undo_single', 'post_id' => $postId], admin_url('admin-post.php')),
                    'wpsn_bulk_undo_' . $postId,
                    'wpsn_nonce'
                );
                echo '<a class="button button-secondary button-small" href="' . esc_url($undoUrl) . '">' . esc_html__('Undo', 'wp-seo-next') . '</a>';
            }

            echo '</td>';

            echo '</tr>';
        }

        wp_reset_postdata();

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

    private function truncateUi(string $text, int $max): string
    {
        $t = trim($text);
        if ($t === '') {
            return '';
        }
        if (function_exists('mb_strlen') && function_exists('mb_substr')) {
            if (mb_strlen($t) > $max) {
                return (string) mb_substr($t, 0, $max - 1) . '…';
            }
            return $t;
        }
        if (strlen($t) > $max) {
            return substr($t, 0, $max - 1) . '…';
        }
        return $t;
    }
}
