<?php

namespace WPSN\Modules\BulkTable\Admin;

use WPSN\Core\Container;
use WPSN\Core\Logger;
use WPSN\Core\Settings;
use WPSN\Core\Queue\QueuePause;
use WPSN\Core\Queue\QueueRunner;
use WPSN\Modules\Media\Admin\MediaAdmin;
use WPSN\Modules\BulkTable\BulkTableJobs;
use WPSN\SEO\MetaKeys;
use WPSN\Core\Capabilities;

defined('ABSPATH') || exit;

final class BulkTableAdmin
{
    private const AJAX_NONCE_ACTION = 'wpsn_bulk_table_import_ajax';
    private const QUEUE_NONCE_ACTION = 'wpsn_bulk_table_import_queue';
    private const REWRITE_PRESETS_OPTION = 'wpsn_bulk_table_rewrite_presets';

    // Prevent multiple concurrent post-response pumps.
    private const IMPORT_PUMP_LOCK_TRANSIENT = 'wpsn_bulk_table_import_pump_lock';

    private Container $container;
    private Settings $settings;
    private Logger $logger;

    private ?string $hookSuffix = null;

    public function __construct(Container $container)
    {
        $this->container = $container;
        $this->settings = $container->get('settings');
        $this->logger = $container->get('logger');
    }

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

        // Screen options (per-page rows)
        add_filter('set-screen-option', [$this, 'setScreenOption'], 10, 3);

        add_action('admin_post_wpsn_bulk_table_save', [$this, 'handleSave']);
        add_action('admin_post_wpsn_bulk_table_export', [$this, 'handleExport']);
        add_action('admin_post_wpsn_bulk_table_export_json', [$this, 'handleExportJson']);
        add_action('admin_post_wpsn_bulk_table_import', [$this, 'handleImportPreview']);
        add_action('admin_post_wpsn_bulk_table_import_json', [$this, 'handleImportPreviewJson']);
        add_action('admin_post_wpsn_bulk_table_import_apply', [$this, 'handleImportApply']);
        add_action('admin_post_wpsn_bulk_table_import_apply_queue', [$this, 'handleImportApplyQueue']);
        add_action('admin_post_wpsn_bulk_table_import_apply_queue_cancel', [$this, 'handleImportApplyQueueCancel']);
        add_action('admin_post_wpsn_bulk_table_import_apply_queue_worker', [$this, 'handleImportApplyQueueWorker']);

        // Term bulk editor actions.
        add_action('admin_post_wpsn_bulk_table_terms_save', [$this, 'handleSaveTerms']);
        add_action('admin_post_wpsn_bulk_table_terms_export', [$this, 'handleExportTerms']);
        add_action('admin_post_wpsn_bulk_table_terms_export_json', [$this, 'handleExportJsonTerms']);
        add_action('admin_post_wpsn_bulk_table_terms_import', [$this, 'handleImportPreviewTerms']);
        add_action('admin_post_wpsn_bulk_table_terms_import_json', [$this, 'handleImportPreviewJsonTerms']);
        add_action('admin_post_wpsn_bulk_table_terms_import_apply', [$this, 'handleImportApplyTerms']);
        // Terms share the same queue-first handler (view=terms).

        // AJAX batch-apply for import previews (CSV/JSON).
        add_action('wp_ajax_wpsn_bulk_table_import_apply_start', [$this, 'ajaxImportApplyStart']);
        add_action('wp_ajax_wpsn_bulk_table_import_apply_step', [$this, 'ajaxImportApplyStep']);
        add_action('wp_ajax_wpsn_bulk_table_import_apply_cancel', [$this, 'ajaxImportApplyCancel']);

        // Rewrite rules presets (staging → production mapping helpers).
        add_action('admin_post_wpsn_bulk_table_rewrite_preset_save', [$this, 'handleRewritePresetSave']);
        add_action('admin_post_wpsn_bulk_table_rewrite_preset_delete', [$this, 'handleRewritePresetDelete']);
        add_action('admin_post_wpsn_bulk_table_rewrite_presets_import', [$this, 'handleRewritePresetsImport']);
        add_action('wp_ajax_wpsn_bulk_table_rewrite_preset_save', [$this, 'ajaxRewritePresetSave']);
        add_action('wp_ajax_wpsn_bulk_table_rewrite_preset_delete', [$this, 'ajaxRewritePresetDelete']);

        // Presets export/import (portable JSON).
        add_action('admin_post_wpsn_bulk_table_rewrite_presets_export', [$this, 'handleExportRewritePresets']);
        add_action('wp_ajax_wpsn_bulk_table_rewrite_presets_import', [$this, 'ajaxRewritePresetsImport']);
    }

    private function queueSessionKey(string $sessionId): string
    {
        return 'wpsn_bulk_table_import_queue_' . sanitize_key($sessionId);
    }

    private function queueActiveKey(string $view): string
    {
        $view = in_array($view, ['posts', 'terms'], true) ? $view : 'posts';
        $uid = (int) get_current_user_id();
        return 'wpsn_bulk_table_import_queue_active_' . $view . '_' . $uid;
    }

    /**
     * Best-effort background pump for Bulk Table import queue jobs.
     *
     * Some shared hosts return HTTP 503 for frequent admin-ajax calls.
     * This pump progresses a small number of jobs after the response is sent.
     */
    private function scheduleShutdownPump(int $maxJobs = 1): void
    {
        if (QueuePause::isPaused()) {
            return;
        }

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

        if (get_transient(self::IMPORT_PUMP_LOCK_TRANSIENT)) {
            return;
        }
        set_transient(self::IMPORT_PUMP_LOCK_TRANSIENT, (string) time(), 15);

        /** @var QueueRunner $runner */
        $runner = $this->container->get('queue_runner');
        $logger = $this->logger;

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

        add_action('shutdown', static function () use ($runner, $logger, $maxJobs): void {
            if (function_exists('fastcgi_finish_request')) {
                @fastcgi_finish_request();
            }

            try {
                $runner->runOnlyTypes([
                    BulkTableJobs::JOB_IMPORT_APPLY,
                ], $maxJobs);
            } catch (\Throwable $e) {
                $logger->error('Bulk Table import shutdown pump failed: ' . $e->getMessage());
            }
        }, 0);
    }

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

        if (is_string($this->hookSuffix) && $this->hookSuffix !== '') {
            add_action('load-' . $this->hookSuffix, [$this, 'addScreenOptions']);
        }
    }

    public function addScreenOptions(): void
    {
        add_screen_option('per_page', [
            'label'   => __('Rows per page', 'wp-seo-next'),
            'default' => 20,
            'option'  => 'wpsn_bulk_table_per_page',
        ]);
    }

    /**
     * Persist custom screen option.
     *
     * @param mixed $status
     * @param string $option
     * @param int $value
     * @return mixed
     */
    public function setScreenOption($status, string $option, int $value)
    {
        if ($option === 'wpsn_bulk_table_per_page') {
            $v = (int) $value;
            return $v > 0 ? $v : 20;
        }
        return $status;
    }

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

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

        // Media tab reuses the SEO Media admin UI.
        if ($this->currentView() === 'media') {
            wp_enqueue_style(
                'wpsn-media-admin',
                WPSN_PLUGIN_URL . 'assets/admin/media.css',
                [],
                WPSN_VERSION
            );

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

            wp_localize_script(
                'wpsn-media-admin',
                'WPSN_MEDIA',
                [
                    'ajaxUrl' => admin_url('admin-ajax.php'),
                    'nonce' => wp_create_nonce(MediaAdmin::AJAX_NONCE_ACTION),
                    'strings' => [
                        'generating' => __('Generating…', 'wp-seo-next'),
                        'applying' => __('Applying…', 'wp-seo-next'),
                        'done' => __('Done.', 'wp-seo-next'),
                        'canceled' => __('Canceled.', 'wp-seo-next'),
                    ],
                ]
            );

            return;
        }

        // Import apply (CSV/JSON) via AJAX with progress.
        wp_enqueue_script(
            'wpsn-bulk-table-import',
            WPSN_PLUGIN_URL . 'assets/admin/bulk-table-import.js',
            ['jquery'],
            WPSN_VERSION,
            true
        );

        wp_localize_script(
            'wpsn-bulk-table-import',
            'WPSN_BULK_IMPORT',
            [
                'ajaxUrl' => admin_url('admin-ajax.php'),
                'nonce' => wp_create_nonce(self::AJAX_NONCE_ACTION),
                'rewritePresets' => $this->getRewritePresets(),
                'strings' => [
                    'starting' => __('Starting…', 'wp-seo-next'),
                    'started' => __('Started. Total rows: %total%.', 'wp-seo-next'),
                    'progress' => __('Processed %processed%/%total% rows. Updated fields: %applied%.', 'wp-seo-next'),
                    'canceling' => __('Canceling…', 'wp-seo-next'),
                    'canceled' => __('Canceled.', 'wp-seo-next'),
                    'error' => __('Error.', 'wp-seo-next'),

                    // Presets
                    'preset_replace_confirm' => __('Replace current rewrite rules with the selected preset?', 'wp-seo-next'),
                    'preset_delete_confirm' => __('Delete this preset?', 'wp-seo-next'),
                    'preset_name_required' => __('Please enter a preset name.', 'wp-seo-next'),
                    'preset_rules_required' => __('Please enter valid rewrite rules before saving a preset.', 'wp-seo-next'),
                    'preset_saved' => __('Preset saved.', 'wp-seo-next'),
                    'preset_deleted' => __('Preset deleted.', 'wp-seo-next'),

                    'preset_importing' => __('Importing…', 'wp-seo-next'),
                    'preset_import_select_file' => __('Please select a JSON file to import.', 'wp-seo-next'),
                    'preset_import_replace_confirm' => __('Replace all existing presets? This will overwrite your current list.', 'wp-seo-next'),
                    'preset_imported' => __('Presets imported.', 'wp-seo-next'),
                ],
                'pageUrlBase' => admin_url('tools.php?page=wpsn-seo-bulk-table'),
            ]
        );
    }

    private function currentView(): string
    {
        $view = isset($_GET['view']) ? sanitize_key((string) wp_unslash($_GET['view'])) : 'posts';
        return in_array($view, ['posts', 'terms', 'media'], true) ? $view : 'posts';
    }

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

    private function redirectWithNotice(string $msg, string $type = 'success', array $args = []): void
    {
        $url = $this->pageUrl(array_merge($args, [
            'wpsn_notice' => rawurlencode($msg),
            'wpsn_notice_type' => $type,
        ]));
        wp_safe_redirect($url);
        exit;
    }

    /**
     * @return array{pt:string,status:string,filter:string,s:string}
     */
    private function currentPostFilters(): array
    {
        $pt = isset($_GET['pt']) ? sanitize_key((string) wp_unslash($_GET['pt'])) : 'any';
        if ($pt !== 'any' && $pt !== '' && !post_type_exists($pt)) {
            $pt = 'any';
        }

        $status = isset($_GET['status']) ? sanitize_key((string) wp_unslash($_GET['status'])) : 'publish';
        $allowedStatus = ['publish', 'draft', 'pending', 'private', 'future', 'any'];
        if (!in_array($status, $allowedStatus, true)) {
            $status = 'publish';
        }

        $filter = isset($_GET['filter']) ? sanitize_key((string) wp_unslash($_GET['filter'])) : '';
        $allowedFilters = ['', 'empty_title', 'empty_desc', 'long_title', 'long_desc', 'duplicates_title', 'template_mismatch', 'template_mismatch_og', 'has_overrides', 'no_overrides'];
        if (!in_array($filter, $allowedFilters, true)) {
            $filter = '';
        }

        $s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';

        return [
            'pt' => $pt,
            'status' => $status,
            'filter' => $filter,
            's' => $s,
        ];
    }

    /**
     * @return array{tax:string,filter:string,s:string}
     */
    private function currentTermFilters(): array
    {
        $tax = isset($_GET['tax']) ? sanitize_key((string) wp_unslash($_GET['tax'])) : 'any';
        $publicTax = get_taxonomies(['public' => true], 'names');
        if ($tax !== 'any' && $tax !== '' && !in_array($tax, $publicTax, true)) {
            $tax = 'any';
        }

        $filter = isset($_GET['filter']) ? sanitize_key((string) wp_unslash($_GET['filter'])) : '';
        $allowedFilters = ['', 'empty_title', 'empty_desc', 'empty_og_image', 'template_mismatch_og', 'has_overrides', 'no_overrides'];
        if (!in_array($filter, $allowedFilters, true)) {
            $filter = '';
        }

        $s = isset($_GET['s']) ? sanitize_text_field((string) wp_unslash($_GET['s'])) : '';

        return [
            'tax' => $tax,
            'filter' => $filter,
            's' => $s,
        ];
    }

    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'));
        }

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

        $view = $this->currentView();

        // Media tab: embed the SEO Media tool (ALT + Filenames) inside Bulk Editor.
        if ($view === 'media') {
            /** @var \WPSN\Core\Queue\QueueInterface $queue */
            $queue = $this->container->get('queue');
            /** @var \WPSN\Core\Queue\QueueRunner $runner */
            $runner = $this->container->get('queue_runner');
            /** @var \WPSN\Core\Settings $settings */
            $settings = $this->container->get('settings');
            /** @var \WPSN\Core\Logger $logger */
            $logger = $this->container->get('logger');

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

                <h2 class="nav-tab-wrapper" style="margin-top: 10px;">
                    <a href="<?php echo esc_url($this->pageUrl(['view' => 'posts'])); ?>" class="nav-tab <?php echo $view === 'posts' ? 'nav-tab-active' : ''; ?>">
                        <?php echo esc_html__('Posts', 'wp-seo-next'); ?>
                    </a>
                    <a href="<?php echo esc_url($this->pageUrl(['view' => 'terms'])); ?>" class="nav-tab <?php echo $view === 'terms' ? 'nav-tab-active' : ''; ?>">
                        <?php echo esc_html__('Terms', 'wp-seo-next'); ?>
                    </a>
                    <a href="<?php echo esc_url($this->pageUrl(['view' => 'media'])); ?>" class="nav-tab nav-tab-active">
                        <?php echo esc_html__('Media', 'wp-seo-next'); ?>
                    </a>
                </h2>

                <p class="description">
                    <?php echo esc_html__('Bulk tools for Media: generate/apply ALT suggestions and safely rename image filenames with rollback.', 'wp-seo-next'); ?>
                </p>

                <hr class="wp-header-end" />

                <?php
                $mediaAdmin = new MediaAdmin(
                    $queue,
                    $runner,
                    $settings,
                    $logger,
                    'wpsn-seo-bulk-table',
                    ['view' => 'media'],
                    'media_'
                );
                $mediaAdmin->renderEmbedded(__('SEO Media', 'wp-seo-next'));
                ?>

            </div>
            <?php
            return;
        }

        $filters = ($view === 'terms') ? $this->currentTermFilters() : $this->currentPostFilters();

        // Import preview (token)
        $previewToken = isset($_GET['import_preview']) ? sanitize_key((string) wp_unslash($_GET['import_preview'])) : '';
        $preview = null;
        if ($previewToken !== '') {
            $preview = get_transient($this->previewTransientKey($previewToken, $view));
            if (!is_array($preview)) {
                $preview = null;
            }
        }

        $saveFormId = 'wpsn-bulk-table-save-form';

        if ($view === 'terms') {
            $table = new TermBulkTableListTable($this->container, $filters, $saveFormId);
            $table->prepare_items();
            $exportUrl = wp_nonce_url(
                admin_url('admin-post.php?action=wpsn_bulk_table_terms_export') . '&' . http_build_query($filters) . '&view=terms',
                'wpsn_bulk_table_terms_export'
            );

            $exportJsonUrl = wp_nonce_url(
                admin_url('admin-post.php?action=wpsn_bulk_table_terms_export_json') . '&' . http_build_query($filters) . '&view=terms',
                'wpsn_bulk_table_terms_export_json'
            );
        } else {
            $table = new BulkTableListTable($this->container, $filters, $saveFormId);
            $table->prepare_items();
            $exportUrl = wp_nonce_url(
                admin_url('admin-post.php?action=wpsn_bulk_table_export') . '&' . http_build_query($filters) . '&view=posts',
                'wpsn_bulk_table_export'
            );

            $exportJsonUrl = wp_nonce_url(
                admin_url('admin-post.php?action=wpsn_bulk_table_export_json') . '&' . http_build_query($filters) . '&view=posts',
                'wpsn_bulk_table_export_json'
            );
        }

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

            <h2 class="nav-tab-wrapper" style="margin-top: 10px;">
                <a href="<?php echo esc_url($this->pageUrl(['view' => 'posts'])); ?>" class="nav-tab <?php echo $view === 'posts' ? 'nav-tab-active' : ''; ?>">
                    <?php echo esc_html__('Posts', 'wp-seo-next'); ?>
                </a>
                <a href="<?php echo esc_url($this->pageUrl(['view' => 'terms'])); ?>" class="nav-tab <?php echo $view === 'terms' ? 'nav-tab-active' : ''; ?>">
                    <?php echo esc_html__('Terms', 'wp-seo-next'); ?>
                </a>
                <a href="<?php echo esc_url($this->pageUrl(['view' => 'media'])); ?>" class="nav-tab <?php echo $view === 'media' ? 'nav-tab-active' : ''; ?>">
                    <?php echo esc_html__('Media', 'wp-seo-next'); ?>
                </a>
            </h2>

            <p class="description">
                <?php echo esc_html__('Spreadsheet-like bulk editor for SEO meta, robots, canonical and social previews. Export/import CSV with a diff preview before applying changes.', 'wp-seo-next'); ?>
            </p>

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

            <div class="wpsn-bulk-table__actions">
                <a class="button" href="<?php echo esc_url($exportUrl); ?>"><?php echo esc_html__('Export CSV', 'wp-seo-next'); ?></a>
                <form method="get" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline-block; margin-left: 6px;">
                    <?php if ($view === 'terms') : ?>
                        <input type="hidden" name="action" value="wpsn_bulk_table_terms_export_json" />
                        <input type="hidden" name="view" value="terms" />
                        <input type="hidden" name="_wpnonce" value="<?php echo esc_attr(wp_create_nonce('wpsn_bulk_table_terms_export_json')); ?>" />
                    <?php else : ?>
                        <input type="hidden" name="action" value="wpsn_bulk_table_export_json" />
                        <input type="hidden" name="view" value="posts" />
                        <input type="hidden" name="_wpnonce" value="<?php echo esc_attr(wp_create_nonce('wpsn_bulk_table_export_json')); ?>" />
                    <?php endif; ?>

                    <?php foreach ($filters as $k => $v) : ?>
                        <input type="hidden" name="<?php echo esc_attr((string) $k); ?>" value="<?php echo esc_attr((string) $v); ?>" />
                    <?php endforeach; ?>

                    <button type="submit" class="button"><?php echo esc_html__('Export JSON (overrides)', 'wp-seo-next'); ?></button>

                    <label style="margin-left: 8px; font-size: 12px;">
                        <input type="checkbox" name="include_empty_fields" value="1" />
                        <?php echo esc_html__('Include empty fields (dangerous)', 'wp-seo-next'); ?>
                    </label>
                </form>
            </div>

            <hr/>

            <h2><?php echo esc_html__('CSV Import', 'wp-seo-next'); ?></h2>
            <p class="description">
                <?php echo esc_html__('Upload a CSV exported from this screen (or compatible). First you will see a diff preview, then you can apply changes. Tip: use __CLEAR__ in a cell to explicitly clear a value. For staging→production workflows, you can map rows by permalink/slug when IDs differ.', 'wp-seo-next'); ?>
            </p>

            <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" enctype="multipart/form-data" class="wpsn-bulk-table__import">
                <?php if ($view === 'terms') : ?>
                    <?php wp_nonce_field('wpsn_bulk_table_terms_import', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_bulk_table_terms_import" />
                <?php else : ?>
                    <?php wp_nonce_field('wpsn_bulk_table_import', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_bulk_table_import" />
                <?php endif; ?>

                <input type="file" name="csv" accept="text/csv,.csv" required />

                <label style="margin-left: 12px;">
                    <input type="checkbox" name="apply_only_changed" value="1" checked />
                    <?php echo esc_html__('Apply only changed values', 'wp-seo-next'); ?>
                </label>

                <span style="margin-left: 12px;">
                    <?php echo esc_html__('Empty cells:', 'wp-seo-next'); ?>
                    <label style="margin-left: 6px;"><input type="radio" name="empty_cells" value="ignore" checked /> <?php echo esc_html__('ignore', 'wp-seo-next'); ?></label>
                    <label style="margin-left: 6px;"><input type="radio" name="empty_cells" value="clear" /> <?php echo esc_html__('clear', 'wp-seo-next'); ?></label>
                </span>

                <label style="margin-left: 12px;">
                    <?php echo esc_html__('Clear token:', 'wp-seo-next'); ?>
                    <input type="text" name="clear_token" value="__CLEAR__" class="small-text" />
                </label>

                <span style="margin-left: 12px;">
                    <?php echo esc_html__('Mapping:', 'wp-seo-next'); ?>
                    <?php if ($view === 'terms') : ?>
                        <label style="margin-left: 6px;"><input type="radio" name="mapping" value="auto" checked /> <?php echo esc_html__('Auto (term_id → taxonomy+slug)', 'wp-seo-next'); ?></label>
                    <?php else : ?>
                        <label style="margin-left: 6px;"><input type="radio" name="mapping" value="auto" checked /> <?php echo esc_html__('Auto (post_id → permalink/slug)', 'wp-seo-next'); ?></label>
                    <?php endif; ?>
                    <label style="margin-left: 6px;"><input type="radio" name="mapping" value="id_only" /> <?php echo esc_html__('ID only', 'wp-seo-next'); ?></label>
                </span>

                <?php if ($view !== 'terms') : ?>
                    <details style="margin-left: 12px; margin-top: 10px;">
                        <summary style="cursor: pointer;"><?php echo esc_html__('Permalink rewrite rules (staging → production)', 'wp-seo-next'); ?></summary>
                        <div style="margin-top: 8px; max-width: 680px;">
                            <textarea name="rewrite_rules" rows="3" class="large-text code" placeholder="/staging/ => /"></textarea>
                            <p class="description" style="margin: 6px 0 0;">
                                <?php echo esc_html__('Optional. One rule per line: FROM => TO. Only prefix replacements are applied to the permalink path before mapping (useful when staging is installed in a subfolder).', 'wp-seo-next'); ?>
                            </p>

                            <?php $this->renderRewritePresetsControls(); ?>
                        </div>
                    </details>
                <?php endif; ?>

                <button type="submit" class="button button-primary" style="margin-left: 12px;">
                    <?php echo esc_html__('Upload & Preview', 'wp-seo-next'); ?>
                </button>
            </form>

            <?php if (is_array($preview)) : ?>
                <?php if ($view === 'terms') : ?>
                    <?php $this->renderTermPreview($previewToken, $preview); ?>
                <?php else : ?>
                    <?php $this->renderPreview($previewToken, $preview); ?>
                <?php endif; ?>
            <?php endif; ?>

            <hr/>

            <h2><?php echo esc_html__('JSON Transfer', 'wp-seo-next'); ?></h2>
            <p class="description">
                <?php echo esc_html__('Export/import only override values in a portable JSON format. Designed for staging→production and multi-site workflows: mapping uses id → permalink/slug (posts) or id → taxonomy+slug (terms). To explicitly clear a value, set it to null or use the clear token.', 'wp-seo-next'); ?>
            </p>

            <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" enctype="multipart/form-data" class="wpsn-bulk-table__import">
                <?php if ($view === 'terms') : ?>
                    <?php wp_nonce_field('wpsn_bulk_table_terms_import_json', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_bulk_table_terms_import_json" />
                <?php else : ?>
                    <?php wp_nonce_field('wpsn_bulk_table_import_json', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_bulk_table_import_json" />
                <?php endif; ?>

                <input type="file" name="json" accept="application/json,.json" required />

                <label style="margin-left: 12px;">
                    <input type="checkbox" name="apply_only_changed" value="1" checked />
                    <?php echo esc_html__('Apply only changed values', 'wp-seo-next'); ?>
                </label>

                <span style="margin-left: 12px;">
                    <?php echo esc_html__('Empty fields:', 'wp-seo-next'); ?>
                    <label style="margin-left: 6px;"><input type="radio" name="empty_cells" value="ignore" checked /> <?php echo esc_html__('ignore', 'wp-seo-next'); ?></label>
                    <label style="margin-left: 6px;"><input type="radio" name="empty_cells" value="clear" /> <?php echo esc_html__('clear', 'wp-seo-next'); ?></label>
                </span>

                <label style="margin-left: 12px;">
                    <?php echo esc_html__('Clear token:', 'wp-seo-next'); ?>
                    <input type="text" name="clear_token" value="__CLEAR__" class="small-text" />
                </label>

                <span style="margin-left: 12px;">
                    <?php echo esc_html__('Mapping:', 'wp-seo-next'); ?>
                    <?php if ($view === 'terms') : ?>
                        <label style="margin-left: 6px;"><input type="radio" name="mapping" value="auto" checked /> <?php echo esc_html__('Auto (term_id → taxonomy+slug)', 'wp-seo-next'); ?></label>
                    <?php else : ?>
                        <label style="margin-left: 6px;"><input type="radio" name="mapping" value="auto" checked /> <?php echo esc_html__('Auto (post_id → permalink/slug)', 'wp-seo-next'); ?></label>
                    <?php endif; ?>
                    <label style="margin-left: 6px;"><input type="radio" name="mapping" value="id_only" /> <?php echo esc_html__('ID only', 'wp-seo-next'); ?></label>
                </span>

                <?php if ($view !== 'terms') : ?>
                    <details style="margin-left: 12px; margin-top: 10px;">
                        <summary style="cursor: pointer;"><?php echo esc_html__('Permalink rewrite rules (staging → production)', 'wp-seo-next'); ?></summary>
                        <div style="margin-top: 8px; max-width: 680px;">
                            <textarea name="rewrite_rules" rows="3" class="large-text code" placeholder="/staging/ => /"></textarea>
                            <p class="description" style="margin: 6px 0 0;">
                                <?php echo esc_html__('Optional. One rule per line: FROM => TO. Only prefix replacements are applied to the permalink path before mapping.', 'wp-seo-next'); ?>
                            </p>

                            <?php $this->renderRewritePresetsControls(); ?>
                        </div>
                    </details>
                <?php endif; ?>

                <button type="submit" class="button button-primary" style="margin-left: 12px;">
                    <?php echo esc_html__('Upload & Preview', 'wp-seo-next'); ?>
                </button>
            </form>

            <hr/>

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

            <form id="wpsn-bulk-table-filters-form" method="get" action="<?php echo esc_url(admin_url('tools.php')); ?>">
                <input type="hidden" name="page" value="wpsn-seo-bulk-table" />
                <input type="hidden" name="view" value="<?php echo esc_attr($view); ?>" />
                <?php
                if ($view === 'terms') {
                    $table->search_box(__('Search terms', 'wp-seo-next'), 'wpsn-bulk-table-search');
                } else {
                    $table->search_box(__('Search posts', 'wp-seo-next'), 'wpsn-bulk-table-search');
                }
                ?>
                <?php $table->display(); ?>
            </form>

            <form id="<?php echo esc_attr($saveFormId); ?>" method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                <?php if ($view === 'terms') : ?>
                    <?php wp_nonce_field('wpsn_bulk_table_terms_save', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_bulk_table_terms_save" />
                <?php else : ?>
                    <?php wp_nonce_field('wpsn_bulk_table_save', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_bulk_table_save" />
                <?php endif; ?>

                <?php
                // Preserve filters/search on redirect after save.
                foreach ($filters as $k => $v) {
                    echo '<input type="hidden" name="_filters[' . esc_attr($k) . ']" value="' . esc_attr((string) $v) . '" />';
                }
                echo '<input type="hidden" name="_filters[view]" value="' . esc_attr($view) . '" />';
                ?>

                <p>
                    <button type="submit" name="save_scope" value="page" class="button button-primary">
                        <?php echo esc_html__('Save changes (this page)', 'wp-seo-next'); ?>
                    </button>
                    <button type="submit" name="save_scope" value="selected" class="button">
                        <?php echo esc_html__('Save selected only', 'wp-seo-next'); ?>
                    </button>
                </p>
            </form>
        </div>
        <?php
    }

    private function previewTransientKey(string $token, string $view = 'posts'): string
    {
        $userId = get_current_user_id();
        $view = in_array($view, ['posts', 'terms'], true) ? $view : 'posts';
        return 'wpsn_bulk_table_preview_' . $view . '_' . $userId . '_' . $token;
    }

    /**
     * @return array<string,array{name:string,rules:string}>
     */
    private function getRewritePresets(): array
    {
        $raw = get_option(self::REWRITE_PRESETS_OPTION, []);
        if (!is_array($raw)) {
            return [];
        }

        $presets = [];
        foreach ($raw as $id => $p) {
            if (!is_array($p)) {
                continue;
            }

            $pid = sanitize_key((string) $id);
            if ($pid === '') {
                continue;
            }

            $name = isset($p['name']) ? sanitize_text_field((string) $p['name']) : '';
            $rules = isset($p['rules']) ? (string) $p['rules'] : '';
            $rules = str_replace(["\r\n", "\r"], "\n", $rules);

            if ($name === '' || trim($rules) === '') {
                continue;
            }

            $presets[$pid] = [
                'name' => $name,
                'rules' => $rules,
            ];
        }

        uasort($presets, static function ($a, $b): int {
            $an = is_array($a) ? (string) ($a['name'] ?? '') : '';
            $bn = is_array($b) ? (string) ($b['name'] ?? '') : '';
            return strcmp($an, $bn);
        });

        return $presets;
    }

    /**
     * @param array<string,array{name:string,rules:string}> $presets
     */
    private function updateRewritePresets(array $presets): void
    {
        $clean = [];
        $count = 0;
        foreach ($presets as $id => $p) {
            if ($count >= 50) {
                break;
            }
            if (!is_array($p)) {
                continue;
            }
            $pid = sanitize_key((string) $id);
            $name = isset($p['name']) ? sanitize_text_field((string) $p['name']) : '';
            $rules = isset($p['rules']) ? (string) $p['rules'] : '';
            $rules = str_replace(["\r\n", "\r"], "\n", $rules);
            $rules = trim($rules);

            if ($pid === '' || $name === '' || $rules === '') {
                continue;
            }

            // Store only if at least one valid rule exists.
            if (empty(PermalinkRewrite::parseRules($rules))) {
                continue;
            }

            $clean[$pid] = [
                'name' => $name,
                'rules' => $rules,
            ];
            $count++;
        }

        update_option(self::REWRITE_PRESETS_OPTION, $clean, false);
    }

    private function renderRewritePresetsControls(): void
    {
        $presets = $this->getRewritePresets();
        $saveUrl = admin_url('admin-post.php?action=wpsn_bulk_table_rewrite_preset_save');
        $deleteUrl = admin_url('admin-post.php?action=wpsn_bulk_table_rewrite_preset_delete');
        $importUrl = admin_url('admin-post.php?action=wpsn_bulk_table_rewrite_presets_import');
        ?>
        <div class="wpsn-rewrite-presets" style="margin-top: 10px;">
            <label style="display:block; margin-bottom: 4px; font-weight: 600;">
                <?php echo esc_html__('Mapping rules presets', 'wp-seo-next'); ?>
            </label>

            <div style="display:flex; flex-wrap:wrap; gap: 8px; align-items: center;">
                <select name="rewrite_preset_id" style="min-width: 220px;">
                    <option value=""><?php echo esc_html__('— Select preset —', 'wp-seo-next'); ?></option>
                    <?php foreach ($presets as $id => $p) : ?>
                        <option value="<?php echo esc_attr((string) $id); ?>"><?php echo esc_html((string) ($p['name'] ?? $id)); ?></option>
                    <?php endforeach; ?>
                </select>

                <input type="text" name="rewrite_preset_name" class="regular-text" placeholder="<?php echo esc_attr__('Preset name', 'wp-seo-next'); ?>" />
                <?php wp_nonce_field('wpsn_bulk_table_rewrite_preset_save', 'wpsn_rewrite_preset_save_nonce'); ?>
                <?php wp_nonce_field('wpsn_bulk_table_rewrite_preset_delete', 'wpsn_rewrite_preset_delete_nonce'); ?>
                <?php wp_nonce_field('wpsn_bulk_table_rewrite_presets_import', 'wpsn_rewrite_preset_import_nonce'); ?>

                <button type="submit" class="button button-small" formaction="<?php echo esc_url($saveUrl); ?>" formmethod="post" formnovalidate>
                    <?php echo esc_html__('Save preset', 'wp-seo-next'); ?>
                </button>

                <button type="submit" class="button button-small" formaction="<?php echo esc_url($deleteUrl); ?>" formmethod="post" formnovalidate onclick="return confirm('<?php echo esc_js(__('Delete this preset?', 'wp-seo-next')); ?>');">
                    <?php echo esc_html__('Delete preset', 'wp-seo-next'); ?>
                </button>

                <?php
                $exportUrl = wp_nonce_url(
                    admin_url('admin-post.php?action=wpsn_bulk_table_rewrite_presets_export'),
                    'wpsn_bulk_table_rewrite_presets_export'
                );
                ?>

                <a class="button button-small" href="<?php echo esc_url($exportUrl); ?>">
                    <?php echo esc_html__('Export presets', 'wp-seo-next'); ?>
                </a>

                <input type="file" name="rewrite_preset_import_file" accept="application/json,.json" />

                <label class="wpsn-muted" style="display:inline-flex; gap:6px; align-items:center;">
                    <input type="checkbox" name="rewrite_preset_import_replace" value="1" />
                    <?php echo esc_html__('Replace existing', 'wp-seo-next'); ?>
                </label>

                <button type="submit" class="button button-small" formaction="<?php echo esc_url($importUrl); ?>" formmethod="post" formenctype="multipart/form-data" formnovalidate>
                    <?php echo esc_html__('Import presets', 'wp-seo-next'); ?>
                </button>
            </div>

            <p class="description" style="margin: 6px 0 0;">
                <?php echo esc_html__('Direct mode is the default. It works without admin-ajax.php and is safer on busy hosting. Live AJAX mode remains optional below.', 'wp-seo-next'); ?>
            </p>

            <details class="wpsn-ajax-optional" style="margin-top: 8px;">
                <summary><?php echo esc_html__('Advanced: live mode (AJAX, optional)', 'wp-seo-next'); ?></summary>
                <div style="display:flex; flex-wrap:wrap; gap: 8px; align-items: center; margin-top:8px;">
                    <select class="wpsn-rewrite-preset-select" style="min-width: 220px;">
                        <option value=""><?php echo esc_html__('— Select preset —', 'wp-seo-next'); ?></option>
                        <?php foreach ($presets as $id => $p) : ?>
                            <option value="<?php echo esc_attr((string) $id); ?>"><?php echo esc_html((string) ($p['name'] ?? $id)); ?></option>
                        <?php endforeach; ?>
                    </select>

                    <input type="text" class="regular-text wpsn-rewrite-preset-name" placeholder="<?php echo esc_attr__('Preset name', 'wp-seo-next'); ?>" />

                    <button type="button" class="button button-small wpsn-rewrite-preset-save">
                        <?php echo esc_html__('Save preset', 'wp-seo-next'); ?>
                    </button>

                    <button type="button" class="button button-small wpsn-rewrite-preset-delete">
                        <?php echo esc_html__('Delete preset', 'wp-seo-next'); ?>
                    </button>

                    <button type="button" class="button button-small wpsn-rewrite-preset-import">
                        <?php echo esc_html__('Import presets', 'wp-seo-next'); ?>
                    </button>

                    <label class="wpsn-muted" style="display:inline-flex; gap:6px; align-items:center;">
                        <input type="checkbox" class="wpsn-rewrite-preset-import-replace" value="1" />
                        <?php echo esc_html__('Replace existing', 'wp-seo-next'); ?>
                    </label>

                    <input type="file" class="wpsn-rewrite-preset-import-file" accept="application/json,.json" style="display:none;" />

                    <span class="wpsn-rewrite-preset-status wpsn-muted"></span>
                </div>
            </details>

            <p class="description" style="margin: 6px 0 0;">
                <?php echo esc_html__('Presets are stored per-site. Export/import them as JSON to reuse across environments (staging→production) and bulk SEO imports.', 'wp-seo-next'); ?>
            </p>
        </div>
        <?php
    }

    private function presetsReturnUrl(string $fallbackView = 'posts'): string
    {
        $ref = wp_get_referer();
        if (is_string($ref) && $ref !== '') {
            return $ref;
        }

        return $this->pageUrl(['view' => in_array($fallbackView, ['posts', 'terms'], true) ? $fallbackView : 'posts']);
    }

    private function redirectWithPresetNotice(string $message, string $type = 'success', string $fallbackView = 'posts'): void
    {
        $type = in_array($type, ['success', 'error', 'warning', 'info'], true) ? $type : 'success';
        $url = add_query_arg([
            'wpsn_notice' => rawurlencode($message),
            'wpsn_notice_type' => $type,
        ], $this->presetsReturnUrl($fallbackView));
        wp_safe_redirect($url);
        exit;
    }

    private function normalizeIncomingRewritePresets(string $payload, bool $replace, int &$added = 0, int &$updated = 0, int &$skipped = 0, int &$trimmed = 0): array
    {
        $decoded = json_decode($payload, true);
        if (!is_array($decoded)) {
            return [];
        }

        $incoming = $decoded;
        if (isset($decoded['presets']) && is_array($decoded['presets'])) {
            $incoming = $decoded['presets'];
        }
        if (!is_array($incoming)) {
            return [];
        }

        $existing = $this->getRewritePresets();
        $imported = [];
        $added = 0;
        $updated = 0;
        $skipped = 0;

        foreach ($incoming as $id => $p) {
            if (!is_array($p)) {
                $skipped++;
                continue;
            }

            $pid = sanitize_key((string) $id);
            $name = isset($p['name']) ? sanitize_text_field((string) $p['name']) : '';
            $rules = isset($p['rules']) ? (string) $p['rules'] : '';
            $rules = str_replace(["
", "
"], "
", $rules);
            $rules = trim($rules);

            if ($name === '' || $rules === '' || empty(PermalinkRewrite::parseRules($rules))) {
                $skipped++;
                continue;
            }

            if ($pid === '') {
                $pid = substr(sha1($name), 0, 12);
            }

            if (isset($existing[$pid])) {
                $updated++;
            } else {
                $added++;
            }

            $imported[$pid] = [
                'name' => $name,
                'rules' => $rules,
            ];
        }

        if (empty($imported)) {
            return [];
        }

        if ($replace) {
            $merged = $imported;
        } else {
            $merged = $imported;
            foreach ($existing as $eid => $ep) {
                if (!isset($merged[$eid])) {
                    $merged[$eid] = $ep;
                }
            }
        }

        $this->updateRewritePresets($merged);
        $final = $this->getRewritePresets();
        $trimmed = max(0, count($merged) - count($final));
        return $final;
    }

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

        check_admin_referer('wpsn_bulk_table_rewrite_preset_save', 'wpsn_rewrite_preset_save_nonce');

        $presetId = isset($_POST['rewrite_preset_id']) ? sanitize_key((string) wp_unslash($_POST['rewrite_preset_id'])) : '';
        $name = isset($_POST['rewrite_preset_name']) ? sanitize_text_field((string) wp_unslash($_POST['rewrite_preset_name'])) : '';
        $rules = isset($_POST['rewrite_rules']) ? (string) wp_unslash($_POST['rewrite_rules']) : '';
        $rules = str_replace(["
", "
"], "
", $rules);
        $rules = trim($rules);
        $fallbackView = isset($_POST['view']) ? sanitize_key((string) wp_unslash($_POST['view'])) : 'posts';

        if ($name === '') {
            $this->redirectWithPresetNotice(__('Preset name is required.', 'wp-seo-next'), 'error', $fallbackView);
        }

        if ($rules === '' || empty(PermalinkRewrite::parseRules($rules))) {
            $this->redirectWithPresetNotice(__('Please enter at least one valid rewrite rule (FROM => TO).', 'wp-seo-next'), 'error', $fallbackView);
        }

        if ($presetId === '') {
            $presetId = substr(sha1($name), 0, 12);
        }

        $presets = $this->getRewritePresets();
        $presets[$presetId] = [
            'name' => $name,
            'rules' => $rules,
        ];
        $this->updateRewritePresets($presets);

        $this->redirectWithPresetNotice(__('Preset saved.', 'wp-seo-next'), 'success', $fallbackView);
    }

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

        check_admin_referer('wpsn_bulk_table_rewrite_preset_delete', 'wpsn_rewrite_preset_delete_nonce');

        $presetId = isset($_POST['rewrite_preset_id']) ? sanitize_key((string) wp_unslash($_POST['rewrite_preset_id'])) : '';
        $fallbackView = isset($_POST['view']) ? sanitize_key((string) wp_unslash($_POST['view'])) : 'posts';
        if ($presetId === '') {
            $this->redirectWithPresetNotice(__('Missing preset.', 'wp-seo-next'), 'error', $fallbackView);
        }

        $presets = $this->getRewritePresets();
        if (!isset($presets[$presetId])) {
            $this->redirectWithPresetNotice(__('Preset not found.', 'wp-seo-next'), 'error', $fallbackView);
        }

        unset($presets[$presetId]);
        $this->updateRewritePresets($presets);
        $this->redirectWithPresetNotice(__('Preset deleted.', 'wp-seo-next'), 'success', $fallbackView);
    }

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

        check_admin_referer('wpsn_bulk_table_rewrite_presets_import', 'wpsn_rewrite_preset_import_nonce');

        $fallbackView = isset($_POST['view']) ? sanitize_key((string) wp_unslash($_POST['view'])) : 'posts';
        $replace = !empty($_POST['rewrite_preset_import_replace']);

        if (!isset($_FILES['rewrite_preset_import_file']) || !is_array($_FILES['rewrite_preset_import_file'])) {
            $this->redirectWithPresetNotice(__('Please select a JSON file to import.', 'wp-seo-next'), 'error', $fallbackView);
        }

        $file = $_FILES['rewrite_preset_import_file'];
        if (!empty($file['error']) || !isset($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
            $this->redirectWithPresetNotice(__('Please select a valid JSON file to import.', 'wp-seo-next'), 'error', $fallbackView);
        }

        $payload = @file_get_contents($file['tmp_name']);
        if (!is_string($payload) || trim($payload) === '') {
            $this->redirectWithPresetNotice(__('Missing JSON payload.', 'wp-seo-next'), 'error', $fallbackView);
        }

        $added = 0;
        $updated = 0;
        $skipped = 0;
        $trimmed = 0;
        $final = $this->normalizeIncomingRewritePresets($payload, $replace, $added, $updated, $skipped, $trimmed);
        if (empty($final)) {
            $this->redirectWithPresetNotice(__('No valid presets found in file.', 'wp-seo-next'), 'error', $fallbackView);
        }

        $msg = sprintf(
            __('Imported %1$d presets (added %2$d, updated %3$d, skipped %4$d).', 'wp-seo-next'),
            $added + $updated,
            $added,
            $updated,
            $skipped
        );
        if ($trimmed > 0) {
            $msg .= ' ' . sprintf(__('Note: preset limit is 50; %d presets were ignored.', 'wp-seo-next'), $trimmed);
        }

        $this->redirectWithPresetNotice($msg, 'success', $fallbackView);
    }

    /**
     * AJAX: save (create/update) a rewrite preset.
     */
    public function ajaxRewritePresetSave(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => __('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::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => __('Invalid nonce.', 'wp-seo-next')], 400);
        }

        $presetId = isset($_POST['preset_id']) ? sanitize_key((string) wp_unslash($_POST['preset_id'])) : '';
        $name = isset($_POST['name']) ? sanitize_text_field((string) wp_unslash($_POST['name'])) : '';
        $rules = isset($_POST['rules']) ? (string) wp_unslash($_POST['rules']) : '';
        $rules = str_replace(["\r\n", "\r"], "\n", $rules);
        $rules = trim($rules);

        if ($name === '') {
            wp_send_json_error(['message' => __('Preset name is required.', 'wp-seo-next')], 400);
        }

        if ($rules === '' || empty(PermalinkRewrite::parseRules($rules))) {
            wp_send_json_error(['message' => __('Please enter at least one valid rewrite rule (FROM => TO).', 'wp-seo-next')], 400);
        }

        if ($presetId === '') {
            $presetId = substr(sha1($name), 0, 12);
        }

        $presets = $this->getRewritePresets();
        $presets[$presetId] = [
            'name' => $name,
            'rules' => $rules,
        ];
        $this->updateRewritePresets($presets);

        wp_send_json_success([
            'preset_id' => $presetId,
            'presets' => $this->getRewritePresets(),
            'message' => __('Preset saved.', 'wp-seo-next'),
        ]);
    }

    /**
     * AJAX: delete a rewrite preset.
     */
    public function ajaxRewritePresetDelete(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => __('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::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => __('Invalid nonce.', 'wp-seo-next')], 400);
        }

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

        $presets = $this->getRewritePresets();
        if (!isset($presets[$presetId])) {
            wp_send_json_error(['message' => __('Preset not found.', 'wp-seo-next')], 404);
        }

        unset($presets[$presetId]);
        $this->updateRewritePresets($presets);

        wp_send_json_success([
            'presets' => $this->getRewritePresets(),
            'message' => __('Preset deleted.', 'wp-seo-next'),
        ]);
    }

    /**
     * Export rewrite presets as JSON download (agency / staging→production portability).
     */
    public function handleExportRewritePresets(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_bulk_table_rewrite_presets_export');

        $payload = [
            'plugin'      => 'wp-seo-next',
            'version'     => defined('WPSN_VERSION') ? WPSN_VERSION : '',
            'exported_at' => gmdate('c'),
            'presets'     => $this->getRewritePresets(),
        ];

        $json = wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
        if (!is_string($json) || $json === '') {
            $json = '{}';
        }

        $filename = 'wpsn-rewrite-presets-' . gmdate('Ymd-His') . '.json';

        nocache_headers();
        header('Content-Type: application/json; charset=' . get_option('blog_charset'));
        header('Content-Disposition: attachment; filename=' . $filename);
        echo $json;
        exit;
    }

    /**
     * AJAX: import rewrite presets from a JSON payload (merge or replace).
     */
    public function ajaxRewritePresetsImport(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => __('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::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => __('Invalid nonce.', 'wp-seo-next')], 400);
        }

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

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

        $decoded = json_decode($payload, true);
        if (!is_array($decoded)) {
            wp_send_json_error(['message' => __('Invalid JSON.', 'wp-seo-next')], 400);
        }

        $incoming = $decoded;
        if (isset($decoded['presets']) && is_array($decoded['presets'])) {
            $incoming = $decoded['presets'];
        }

        if (!is_array($incoming)) {
            wp_send_json_error(['message' => __('Invalid presets payload.', 'wp-seo-next')], 400);
        }

        $existing = $this->getRewritePresets();

        $imported = [];
        $added = 0;
        $updated = 0;
        $skipped = 0;

        foreach ($incoming as $id => $p) {
            if (!is_array($p)) {
                $skipped++;
                continue;
            }

            $pid = sanitize_key((string) $id);
            $name = isset($p['name']) ? sanitize_text_field((string) $p['name']) : '';
            $rules = isset($p['rules']) ? (string) $p['rules'] : '';
            $rules = str_replace(["\r\n", "\r"], "\n", $rules);
            $rules = trim($rules);

            if ($name === '' || $rules === '') {
                $skipped++;
                continue;
            }

            if (empty(PermalinkRewrite::parseRules($rules))) {
                $skipped++;
                continue;
            }

            if ($pid === '') {
                $pid = substr(sha1($name), 0, 12);
            }

            if (isset($existing[$pid])) {
                $updated++;
            } else {
                $added++;
            }

            $imported[$pid] = [
                'name'  => $name,
                'rules' => $rules,
            ];
        }

        if (empty($imported)) {
            wp_send_json_error(['message' => __('No valid presets found in file.', 'wp-seo-next')], 400);
        }

        $merged = [];
        if ($replace) {
            $merged = $imported;
        } else {
            // Prioritize imported presets if hitting the 50 preset cap.
            $merged = $imported;
            foreach ($existing as $eid => $ep) {
                if (!isset($merged[$eid])) {
                    $merged[$eid] = $ep;
                }
            }
        }

        $this->updateRewritePresets($merged);
        $final = $this->getRewritePresets();

        $trimmed = max(0, count($merged) - count($final));

        /* translators: 1: imported total, 2: added, 3: updated, 4: skipped */
        $msg = sprintf(
            __('Imported %1$d presets (added %2$d, updated %3$d, skipped %4$d).', 'wp-seo-next'),
            $added + $updated,
            $added,
            $updated,
            $skipped
        );
        if ($trimmed > 0) {
            /* translators: %d is number of ignored presets */
            $msg .= ' ' . sprintf(__('Note: preset limit is 50; %d presets were ignored.', 'wp-seo-next'), $trimmed);
        }

        wp_send_json_success([
            'presets'  => $final,
            'added'    => $added,
            'updated'  => $updated,
            'skipped'  => $skipped,
            'message'  => $msg,
        ]);
    }

    private function renderPreview(string $token, array $preview): void
    {
        $changes = isset($preview['changes']) && is_array($preview['changes']) ? $preview['changes'] : [];
        $errors = isset($preview['errors']) && is_array($preview['errors']) ? $preview['errors'] : [];

        $totalChanges = (int) ($preview['total_changes'] ?? count($changes));
        $totalRows = (int) ($preview['total_rows'] ?? 0);

        ?>
        <div class="wpsn-bulk-table__preview">
            <h3><?php echo esc_html__('Import preview', 'wp-seo-next'); ?></h3>

            <p>
                <?php
                /* translators: 1: row count, 2: change count */
                echo esc_html(sprintf(__('Parsed %1$d rows. Detected %2$d changes.', 'wp-seo-next'), $totalRows, $totalChanges));
                ?>
            </p>

            <?php if (!empty($errors)) : ?>
                <div class="notice notice-warning"><p><strong><?php echo esc_html__('Warnings', 'wp-seo-next'); ?></strong></p>
                    <ul style="margin-left: 18px; list-style: disc;">
                        <?php foreach (array_slice($errors, 0, 10) as $e) : ?>
                            <li><?php echo esc_html((string) $e); ?></li>
                        <?php endforeach; ?>
                    </ul>
                    <?php if (count($errors) > 10) : ?>
                        <p><?php echo esc_html__('(Showing first 10 warnings.)', 'wp-seo-next'); ?></p>
                    <?php endif; ?>
                </div>
            <?php endif; ?>

            <?php if (empty($changes)) : ?>
                <div class="notice notice-info"><p><?php echo esc_html__('No changes detected. Nothing to apply.', 'wp-seo-next'); ?></p></div>
            <?php else : ?>
                <?php $queueSession = $this->getImportQueueSession($token, 'posts'); ?>
                <?php $queueStatus = is_array($queueSession) ? (string) ($queueSession['status'] ?? '') : ''; ?>
                <?php $queueRunning = in_array($queueStatus, ['running', 'queued'], true); ?>
                <?php $hasOps = isset($preview['ops']) && is_array($preview['ops']) && !empty($preview['ops']); ?>

                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-bottom: 10px;">
                    <?php wp_nonce_field(self::QUEUE_NONCE_ACTION, 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_bulk_table_import_apply_queue" />
                    <input type="hidden" name="view" value="posts" />
                    <input type="hidden" name="token" value="<?php echo esc_attr($token); ?>" />

                    <button type="submit" class="button button-primary" <?php disabled($queueRunning || !$hasOps); ?>>
                        <?php echo esc_html__('Apply changes (Queue-first)', 'wp-seo-next'); ?>
                    </button>
                </form>

                <?php $this->renderImportQueueStatus($queueSession, $token, 'posts'); ?>

                <details style="margin-top: 10px;">
                    <summary style="cursor:pointer;"><?php echo esc_html__('Advanced apply modes', 'wp-seo-next'); ?></summary>
                    <div style="margin-top: 8px;">
                        <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                            <?php wp_nonce_field('wpsn_bulk_table_import_apply', 'wpsn_nonce'); ?>
                            <input type="hidden" name="action" value="wpsn_bulk_table_import_apply" />
                            <input type="hidden" name="token" value="<?php echo esc_attr($token); ?>" />

                            <button type="submit" class="button" <?php disabled(!$hasOps); ?>>
                                <?php echo esc_html__('Apply immediately (may time out)', 'wp-seo-next'); ?>
                            </button>
                        </form>

                        <div class="wpsn-bulk-import-ajax" data-token="<?php echo esc_attr($token); ?>" data-view="posts" style="margin-top: 10px;<?php echo $hasOps ? '' : 'opacity:0.6; pointer-events:none;'; ?>">
                            <button type="button" class="button wpsn-bulk-ajax-start"><?php echo esc_html__('Apply with progress (AJAX)', 'wp-seo-next'); ?></button>
                            <button type="button" class="button wpsn-bulk-ajax-cancel" style="display:none;"><?php echo esc_html__('Cancel', 'wp-seo-next'); ?></button>
                            <span class="wpsn-bulk-ajax-status wpsn-muted"></span>
                            <div class="wpsn-progress" style="display:none; width: 320px; max-width: 100%;">
                                <div class="wpsn-progress-bar"></div>
                            </div>
                            <div class="wpsn-muted" style="flex-basis: 100%;">
                                <?php echo esc_html__('Tip: if your host rate-limits admin-ajax, prefer Queue-first.', 'wp-seo-next'); ?>
                            </div>
                        </div>

                        <?php if (!$hasOps) : ?>
                            <p class="description"><?php echo esc_html__('This preview no longer contains operations to apply (already applied or expired). Upload again to apply another import.', 'wp-seo-next'); ?></p>
                        <?php endif; ?>
                    </div>
                </details>

                <table class="widefat striped" style="margin-top: 12px;">
                    <thead>
                        <tr>
                            <th><?php echo esc_html__('Post', 'wp-seo-next'); ?></th>
                            <th><?php echo esc_html__('Mapped by', 'wp-seo-next'); ?></th>
                            <th><?php echo esc_html__('Field', 'wp-seo-next'); ?></th>
                            <th><?php echo esc_html__('Current', 'wp-seo-next'); ?></th>
                            <th><?php echo esc_html__('New', 'wp-seo-next'); ?></th>
                        </tr>
                    </thead>
                    <tbody>
                        <?php foreach (array_slice($changes, 0, 200) as $chg) : ?>
                            <tr>
                                <td>
                                    <?php
                                    $pid = (int) ($chg['post_id'] ?? 0);
                                    $title = (string) ($chg['post_title'] ?? '');
                                    $edit = $pid > 0 ? get_edit_post_link($pid) : '';
                                    if ($edit) {
                                        echo '<a href="' . esc_url($edit) . '"><strong>#' . esc_html((string) $pid) . '</strong> ' . esc_html($title) . '</a>';
                                    } else {
                                        echo '<strong>#' . esc_html((string) $pid) . '</strong> ' . esc_html($title);
                                    }
                                    ?>
                                </td>
                                <td>
                                    <?php
                                    $mb = isset($chg['mapped_by']) ? (string) $chg['mapped_by'] : '';
                                    if ($mb === '') {
                                        echo '&mdash;';
                                    } else {
                                        echo '<code>' . esc_html($mb) . '</code>';
                                    }
                                    ?>
                                </td>
                                <td><code><?php echo esc_html((string) ($chg['field'] ?? '')); ?></code></td>
                                <td><span class="wpsn-mono"><?php echo esc_html((string) ($chg['old'] ?? '')); ?></span></td>
                                <td><span class="wpsn-mono"><?php echo esc_html((string) ($chg['new'] ?? '')); ?></span></td>
                            </tr>
                        <?php endforeach; ?>
                    </tbody>
                </table>

                <?php if (count($changes) > 200) : ?>
                    <p class="description"><?php echo esc_html__('Preview is truncated to first 200 changes.', 'wp-seo-next'); ?></p>
                <?php endif; ?>
            <?php endif; ?>
        </div>
        <?php
    }

    private function renderTermPreview(string $token, array $preview): void
    {
        $changes = isset($preview['changes']) && is_array($preview['changes']) ? $preview['changes'] : [];
        $errors = isset($preview['errors']) && is_array($preview['errors']) ? $preview['errors'] : [];

        $totalChanges = (int) ($preview['total_changes'] ?? count($changes));
        $totalRows = (int) ($preview['total_rows'] ?? 0);

        ?>
        <div class="wpsn-bulk-table__preview">
            <h3><?php echo esc_html__('Import preview', 'wp-seo-next'); ?></h3>

            <p>
                <?php
                /* translators: 1: row count, 2: change count */
                echo esc_html(sprintf(__('Parsed %1$d rows. Detected %2$d changes.', 'wp-seo-next'), $totalRows, $totalChanges));
                ?>
            </p>

            <?php if (!empty($errors)) : ?>
                <div class="notice notice-warning"><p><strong><?php echo esc_html__('Warnings', 'wp-seo-next'); ?></strong></p>
                    <ul style="margin-left: 18px; list-style: disc;">
                        <?php foreach (array_slice($errors, 0, 10) as $e) : ?>
                            <li><?php echo esc_html((string) $e); ?></li>
                        <?php endforeach; ?>
                    </ul>
                    <?php if (count($errors) > 10) : ?>
                        <p><?php echo esc_html__('(Showing first 10 warnings.)', 'wp-seo-next'); ?></p>
                    <?php endif; ?>
                </div>
            <?php endif; ?>

            <?php if (empty($changes)) : ?>
                <div class="notice notice-info"><p><?php echo esc_html__('No changes detected. Nothing to apply.', 'wp-seo-next'); ?></p></div>
            <?php else : ?>
                <?php $queueSession = $this->getImportQueueSession($token, 'terms'); ?>
                <?php $queueStatus = is_array($queueSession) ? (string) ($queueSession['status'] ?? '') : ''; ?>
                <?php $queueRunning = in_array($queueStatus, ['running', 'queued'], true); ?>
                <?php $hasOps = isset($preview['ops']) && is_array($preview['ops']) && !empty($preview['ops']); ?>

                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-bottom: 10px;">
                    <?php wp_nonce_field(self::QUEUE_NONCE_ACTION, 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_bulk_table_import_apply_queue" />
                    <input type="hidden" name="view" value="terms" />
                    <input type="hidden" name="token" value="<?php echo esc_attr($token); ?>" />

                    <button type="submit" class="button button-primary" <?php disabled($queueRunning || !$hasOps); ?>>
                        <?php echo esc_html__('Apply changes (Queue-first)', 'wp-seo-next'); ?>
                    </button>
                </form>

                <?php $this->renderImportQueueStatus($queueSession, $token, 'terms'); ?>

                <details style="margin-top: 10px;">
                    <summary style="cursor:pointer;"><?php echo esc_html__('Advanced apply modes', 'wp-seo-next'); ?></summary>
                    <div style="margin-top: 8px;">
                        <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                            <?php wp_nonce_field('wpsn_bulk_table_terms_import_apply', 'wpsn_nonce'); ?>
                            <input type="hidden" name="action" value="wpsn_bulk_table_terms_import_apply" />
                            <input type="hidden" name="token" value="<?php echo esc_attr($token); ?>" />

                            <button type="submit" class="button" <?php disabled(!$hasOps); ?>>
                                <?php echo esc_html__('Apply immediately (may time out)', 'wp-seo-next'); ?>
                            </button>
                        </form>

                        <div class="wpsn-bulk-import-ajax" data-token="<?php echo esc_attr($token); ?>" data-view="terms" style="margin-top: 10px;<?php echo $hasOps ? '' : 'opacity:0.6; pointer-events:none;'; ?>">
                            <button type="button" class="button wpsn-bulk-ajax-start"><?php echo esc_html__('Apply with progress (AJAX)', 'wp-seo-next'); ?></button>
                            <button type="button" class="button wpsn-bulk-ajax-cancel" style="display:none;"><?php echo esc_html__('Cancel', 'wp-seo-next'); ?></button>
                            <span class="wpsn-bulk-ajax-status wpsn-muted"></span>
                            <div class="wpsn-progress" style="display:none; width: 320px; max-width: 100%;">
                                <div class="wpsn-progress-bar"></div>
                            </div>
                            <div class="wpsn-muted" style="flex-basis: 100%;">
                                <?php echo esc_html__('Tip: if your host rate-limits admin-ajax, prefer Queue-first.', 'wp-seo-next'); ?>
                            </div>
                        </div>

                        <?php if (!$hasOps) : ?>
                            <p class="description"><?php echo esc_html__('This preview no longer contains operations to apply (already applied or expired). Upload again to apply another import.', 'wp-seo-next'); ?></p>
                        <?php endif; ?>
                    </div>
                </details>

                <table class="widefat striped" style="margin-top: 12px;">
                    <thead>
                        <tr>
                            <th><?php echo esc_html__('Term', 'wp-seo-next'); ?></th>
                            <th><?php echo esc_html__('Mapped by', 'wp-seo-next'); ?></th>
                            <th><?php echo esc_html__('Field', 'wp-seo-next'); ?></th>
                            <th><?php echo esc_html__('Current', 'wp-seo-next'); ?></th>
                            <th><?php echo esc_html__('New', 'wp-seo-next'); ?></th>
                        </tr>
                    </thead>
                    <tbody>
                        <?php foreach (array_slice($changes, 0, 200) as $chg) : ?>
                            <tr>
                                <td>
                                    <?php
                                    $tid = (int) ($chg['term_id'] ?? 0);
                                    $tax = (string) ($chg['taxonomy'] ?? '');
                                    $name = (string) ($chg['term_name'] ?? '');
                                    $edit = ($tid > 0 && $tax !== '') ? get_edit_term_link($tid, $tax) : '';
                                    if ($edit) {
                                        echo '<a href="' . esc_url($edit) . '"><strong>#' . esc_html((string) $tid) . '</strong> ' . esc_html($name) . ' <span class="wpsn-muted">(' . esc_html($tax) . ')</span></a>';
                                    } else {
                                        echo '<strong>#' . esc_html((string) $tid) . '</strong> ' . esc_html($name) . ' <span class="wpsn-muted">(' . esc_html($tax) . ')</span>';
                                    }
                                    ?>
                                </td>
                                <td>
                                    <?php
                                    $mb = isset($chg['mapped_by']) ? (string) $chg['mapped_by'] : '';
                                    if ($mb === '') {
                                        echo '&mdash;';
                                    } else {
                                        echo '<code>' . esc_html($mb) . '</code>';
                                    }
                                    ?>
                                </td>
                                <td><code><?php echo esc_html((string) ($chg['field'] ?? '')); ?></code></td>
                                <td><span class="wpsn-mono"><?php echo esc_html((string) ($chg['old'] ?? '')); ?></span></td>
                                <td><span class="wpsn-mono"><?php echo esc_html((string) ($chg['new'] ?? '')); ?></span></td>
                            </tr>
                        <?php endforeach; ?>
                    </tbody>
                </table>

                <?php if (count($changes) > 200) : ?>
                    <p class="description"><?php echo esc_html__('Preview is truncated to first 200 changes.', 'wp-seo-next'); ?></p>
                <?php endif; ?>
            <?php endif; ?>
        </div>
        <?php
    }

    /**
     * Load active queue-import session for the current user and token.
     *
     * @return array<string,mixed>|null
     */
    private function getImportQueueSession(string $token, string $view): ?array
    {
        $view = in_array($view, ['posts', 'terms'], true) ? $view : 'posts';
        $sid = get_transient($this->queueActiveKey($view));
        if (!is_string($sid) || $sid === '') {
            return null;
        }
        $session = get_transient($this->queueSessionKey($sid));
        if (!is_array($session)) {
            return null;
        }
        $stoken = isset($session['token']) ? sanitize_key((string) $session['token']) : '';
        $sview = isset($session['view']) ? sanitize_key((string) $session['view']) : '';
        if ($stoken === '' || $stoken !== $token || $sview !== $view) {
            return null;
        }
        return $session;
    }

    /**
     * Render status UI for queue-first import apply.
     *
     * @param array<string,mixed>|null $session
     */
    private function renderImportQueueStatus(?array $session, string $token, string $view): void
    {
        if (!is_array($session)) {
            return;
        }

        $status = isset($session['status']) ? (string) $session['status'] : '';
        $offset = isset($session['offset']) ? (int) $session['offset'] : 0;
        $total = isset($session['total_ops']) ? (int) $session['total_ops'] : 0;
        $applied = isset($session['applied_fields']) ? (int) $session['applied_fields'] : 0;
        $lastError = isset($session['last_error']) ? (string) $session['last_error'] : '';
        $sessionId = isset($session['session_id']) ? sanitize_key((string) $session['session_id']) : '';

        if ($total < 0) {
            $total = 0;
        }
        if ($offset < 0) {
            $offset = 0;
        }
        if ($offset > $total) {
            $offset = $total;
        }
        if ($applied < 0) {
            $applied = 0;
        }

        $pct = 0;
        if ($total > 0) {
            $pct = (int) floor(($offset / max(1, $total)) * 100);
            $pct = max(0, min(100, $pct));
        }

        // NOTE: We intentionally do NOT run any post-response “pump” here.
        // The queue should be processed by WP-Cron / server cron / external runner.

        $noticeClass = 'notice-info';
        $title = __('Queue status', 'wp-seo-next');
        if ($status === 'done') {
            $noticeClass = 'notice-success';
            $title = __('Done', 'wp-seo-next');
        } elseif ($status === 'failed') {
            $noticeClass = 'notice-error';
            $title = __('Failed', 'wp-seo-next');
        } elseif ($status === 'canceled') {
            $noticeClass = 'notice-warning';
            $title = __('Canceled', 'wp-seo-next');
        } elseif (in_array($status, ['running', 'queued'], true)) {
            $noticeClass = 'notice-info';
            $title = __('Running', 'wp-seo-next');
        }

        echo '<div class="notice ' . esc_attr($noticeClass) . '"><p><strong>' . esc_html($title) . '</strong></p>';
        echo '<p>' . esc_html(sprintf(
            /* translators: 1: processed ops, 2: total ops, 3: percent */
            __('Progress: %1$d/%2$d ops (%3$d%%).', 'wp-seo-next'),
            (int) $offset,
            (int) $total,
            (int) $pct
        )) . '</p>';
        echo '<p>' . esc_html(sprintf(
            /* translators: %d: number of updated fields */
            __('Updated fields: %d', 'wp-seo-next'),
            (int) $applied
        )) . '</p>';

        if ($status === 'failed' && $lastError !== '') {
            echo '<p><code>' . esc_html($lastError) . '</code></p>';
        }

        // Progress bar.
        echo '<div class="wpsn-progress" style="width: 320px; max-width: 100%; display:block; margin: 8px 0;">';
        echo '<div class="wpsn-progress-bar" style="width:' . esc_attr((string) $pct) . '%;"></div>';
        echo '</div>';

        // Actions.
        echo '<p style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">';

        $refreshUrl = $this->pageUrl([
            'view' => $view,
            'import_preview' => $token,
        ]);
        echo '<a class="button" href="' . esc_url($refreshUrl) . '">' . esc_html__('Refresh', 'wp-seo-next') . '</a>';

        if (in_array($status, ['running', 'queued'], true)) {
            // Cancel.
            if ($sessionId !== '') {
                echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline;">';
                wp_nonce_field(self::QUEUE_NONCE_ACTION, 'wpsn_nonce');
                echo '<input type="hidden" name="action" value="wpsn_bulk_table_import_apply_queue_cancel" />';
                echo '<input type="hidden" name="view" value="' . esc_attr($view) . '" />';
                echo '<input type="hidden" name="token" value="' . esc_attr($token) . '" />';
                echo '<input type="hidden" name="session_id" value="' . esc_attr($sessionId) . '" />';
                echo '<button type="submit" class="button">' . esc_html__('Cancel', 'wp-seo-next') . '</button>';
                echo '</form>';
            }

            $queueUrl = admin_url('tools.php?page=wpsn-queue');
            $queueMode = (string) $this->settings->get('performance.queue.mode', 'auto');
            $bgMsg = $queueMode === 'direct'
                ? __('Direct mode: jobs run at the end of requests (no WP-Cron tick). Refresh later to see progress.', 'wp-seo-next')
                : __('Runs in the background via WP-Cron / server cron. Refresh later to see progress.', 'wp-seo-next');

            echo '<span class="wpsn-muted">' . esc_html($bgMsg) . ' ';
            echo '<a href="' . esc_url($queueUrl) . '">' . esc_html__('Open Queue Center', 'wp-seo-next') . '</a>';
            echo '</span>';
        }

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

    public function handleSave(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_bulk_table_save')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $scope = isset($_POST['save_scope']) ? sanitize_key((string) wp_unslash($_POST['save_scope'])) : 'page';
        if (!in_array($scope, ['page', 'selected'], true)) {
            $scope = 'page';
        }

        $rows = isset($_POST['wpsn_bulk_table']) ? wp_unslash($_POST['wpsn_bulk_table']) : [];
        $rows = is_array($rows) ? $rows : [];

        $selected = isset($_POST['post_ids']) ? wp_unslash($_POST['post_ids']) : [];
        $selected = is_array($selected) ? array_map('intval', $selected) : [];
        $selected = array_filter($selected, static fn($v) => $v > 0);

        $updated = 0;
        $skipped = 0;

        foreach ($rows as $postIdRaw => $fieldsRaw) {
            $postId = (int) $postIdRaw;
            if ($postId <= 0) {
                continue;
            }
            if ($scope === 'selected' && !in_array($postId, $selected, true)) {
                $skipped++;
                continue;
            }
            if (!current_user_can('edit_post', $postId)) {
                $skipped++;
                continue;
            }

            $fields = is_array($fieldsRaw) ? $fieldsRaw : [];
            $updated += $this->applyPostOverrides($postId, $fields);
        }

        $msg = sprintf(
            /* translators: 1: updated count, 2: skipped count */
            __('Saved %1$d fields. Skipped %2$d rows.', 'wp-seo-next'),
            $updated,
            $skipped
        );

        // Preserve filters on redirect.
        $filters = isset($_POST['_filters']) && is_array($_POST['_filters']) ? wp_unslash($_POST['_filters']) : [];
        $filters = is_array($filters) ? $filters : [];
        $safeFilters = [];
        foreach (['pt', 'status', 'filter', 's', 'view'] as $k) {
            if (isset($filters[$k])) {
                $safeFilters[$k] = sanitize_text_field((string) $filters[$k]);
            }
        }

        $safeFilters['view'] = 'posts';
        $this->redirectWithNotice($msg, 'success', $safeFilters);
    }

    public function handleSaveTerms(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_bulk_table_terms_save')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $scope = isset($_POST['save_scope']) ? sanitize_key((string) wp_unslash($_POST['save_scope'])) : 'page';
        if (!in_array($scope, ['page', 'selected'], true)) {
            $scope = 'page';
        }

        $rows = isset($_POST['wpsn_bulk_table_terms']) ? wp_unslash($_POST['wpsn_bulk_table_terms']) : [];
        $rows = is_array($rows) ? $rows : [];

        $selected = isset($_POST['term_ids']) ? wp_unslash($_POST['term_ids']) : [];
        $selected = is_array($selected) ? array_map('intval', $selected) : [];
        $selected = array_filter($selected, static fn($v) => $v > 0);

        $updated = 0;
        $skipped = 0;

        foreach ($rows as $termIdRaw => $fieldsRaw) {
            $termId = (int) $termIdRaw;
            if ($termId <= 0) {
                continue;
            }

            if ($scope === 'selected' && !in_array($termId, $selected, true)) {
                $skipped++;
                continue;
            }

            $fields = is_array($fieldsRaw) ? $fieldsRaw : [];
            $taxonomy = isset($fields['taxonomy']) ? sanitize_key((string) $fields['taxonomy']) : '';
            unset($fields['taxonomy']);

            if ($taxonomy === '' || !taxonomy_exists($taxonomy)) {
                $skipped++;
                continue;
            }

            $term = get_term($termId, $taxonomy);
            if (!$term || is_wp_error($term)) {
                $skipped++;
                continue;
            }

            if (!$this->canEditTerm($termId, $taxonomy)) {
                $skipped++;
                continue;
            }

            $updated += $this->applyTermOverrides($termId, $taxonomy, $fields);
        }

        $msg = sprintf(
            /* translators: 1: updated count, 2: skipped count */
            __('Saved %1$d fields. Skipped %2$d rows.', 'wp-seo-next'),
            $updated,
            $skipped
        );

        // Preserve filters on redirect.
        $filters = isset($_POST['_filters']) && is_array($_POST['_filters']) ? wp_unslash($_POST['_filters']) : [];
        $filters = is_array($filters) ? $filters : [];
        $safeFilters = [];
        foreach (['tax', 'filter', 's', 'view'] as $k) {
            if (isset($filters[$k])) {
                $safeFilters[$k] = sanitize_text_field((string) $filters[$k]);
            }
        }

        $safeFilters['view'] = 'terms';
        $this->redirectWithNotice($msg, 'success', $safeFilters);
    }

    private function canEditTerm(int $termId, string $taxonomy): bool
    {
        // Meta cap works for most taxonomies.
        if (current_user_can('edit_term', $termId)) {
            return true;
        }

        $tax = get_taxonomy($taxonomy);
        if ($tax && isset($tax->cap) && isset($tax->cap->edit_terms) && is_string($tax->cap->edit_terms)) {
            return current_user_can($tax->cap->edit_terms);
        }

        return current_user_can('manage_categories');
    }

    private function applyTermOverrides(int $termId, string $taxonomy, array $fields): int
    {
        $count = 0;

        $title = isset($fields['seo_title']) ? sanitize_text_field((string) $fields['seo_title']) : '';
        $desc  = isset($fields['meta_description']) ? sanitize_textarea_field((string) $fields['meta_description']) : '';
        $canon = isset($fields['canonical']) ? $this->sanitizeUrlAllowRelative((string) $fields['canonical']) : '';

        $rIndex = isset($fields['robots_index']) ? $this->sanitizeRobotsIndex((string) $fields['robots_index']) : '';
        $rFollow = isset($fields['robots_follow']) ? $this->sanitizeRobotsFollow((string) $fields['robots_follow']) : '';

        $ogTitle = isset($fields['og_title']) ? sanitize_text_field((string) $fields['og_title']) : '';
        $ogDesc  = isset($fields['og_description']) ? sanitize_textarea_field((string) $fields['og_description']) : '';
        $ogImg   = isset($fields['og_image']) ? $this->sanitizeImageUrlAllowRelative((string) $fields['og_image']) : '';

        $twTitle = isset($fields['twitter_title']) ? sanitize_text_field((string) $fields['twitter_title']) : '';
        $twDesc  = isset($fields['twitter_description']) ? sanitize_textarea_field((string) $fields['twitter_description']) : '';
        $twImg   = isset($fields['twitter_image']) ? $this->sanitizeImageUrlAllowRelative((string) $fields['twitter_image']) : '';

        $count += $this->updateOrDeleteTermMeta($termId, MetaKeys::TITLE, $title);
        $count += $this->updateOrDeleteTermMeta($termId, MetaKeys::DESCRIPTION, $desc);
        $count += $this->updateOrDeleteTermMeta($termId, MetaKeys::CANONICAL, $canon);

        // Robots tri-state: default => delete
        if ($rIndex !== '' && $rIndex !== 'default') {
            update_term_meta($termId, MetaKeys::ROBOTS_INDEX, $rIndex);
            $count++;
        } else {
            if (delete_term_meta($termId, MetaKeys::ROBOTS_INDEX)) {
                $count++;
            }
        }

        if ($rFollow !== '' && $rFollow !== 'default') {
            update_term_meta($termId, MetaKeys::ROBOTS_FOLLOW, $rFollow);
            $count++;
        } else {
            if (delete_term_meta($termId, MetaKeys::ROBOTS_FOLLOW)) {
                $count++;
            }
        }

        $count += $this->updateOrDeleteTermMeta($termId, MetaKeys::OG_TITLE, $ogTitle);
        $count += $this->updateOrDeleteTermMeta($termId, MetaKeys::OG_DESCRIPTION, $ogDesc);
        $count += $this->updateOrDeleteTermMeta($termId, MetaKeys::OG_IMAGE, $ogImg);

        $count += $this->updateOrDeleteTermMeta($termId, MetaKeys::TWITTER_TITLE, $twTitle);
        $count += $this->updateOrDeleteTermMeta($termId, MetaKeys::TWITTER_DESCRIPTION, $twDesc);
        $count += $this->updateOrDeleteTermMeta($termId, MetaKeys::TWITTER_IMAGE, $twImg);

        // Bust caches.
        $this->container->get('events')->dispatch('seo/meta/updated', [$termId, 'term']);

        return $count;
    }

    private function updateOrDeleteTermMeta(int $termId, string $metaKey, string $value): int
    {
        $value = trim($value);
        if ($value !== '') {
            update_term_meta($termId, $metaKey, $value);
            return 1;
        }
        return delete_term_meta($termId, $metaKey) ? 1 : 0;
    }

    private function applyPostOverrides(int $postId, array $fields): int
    {
        $count = 0;

        $title = isset($fields['seo_title']) ? sanitize_text_field((string) $fields['seo_title']) : '';
        $desc  = isset($fields['meta_description']) ? sanitize_textarea_field((string) $fields['meta_description']) : '';
        $canon = isset($fields['canonical']) ? $this->sanitizeUrlAllowRelative((string) $fields['canonical']) : '';

        $rIndex = isset($fields['robots_index']) ? $this->sanitizeRobotsIndex((string) $fields['robots_index']) : '';
        $rFollow = isset($fields['robots_follow']) ? $this->sanitizeRobotsFollow((string) $fields['robots_follow']) : '';

        $ogTitle = isset($fields['og_title']) ? sanitize_text_field((string) $fields['og_title']) : '';
        $ogDesc  = isset($fields['og_description']) ? sanitize_textarea_field((string) $fields['og_description']) : '';
        $ogImg   = isset($fields['og_image']) ? $this->sanitizeImageUrlAllowRelative((string) $fields['og_image']) : '';

        $twTitle = isset($fields['twitter_title']) ? sanitize_text_field((string) $fields['twitter_title']) : '';
        $twDesc  = isset($fields['twitter_description']) ? sanitize_textarea_field((string) $fields['twitter_description']) : '';
        $twImg   = isset($fields['twitter_image']) ? $this->sanitizeImageUrlAllowRelative((string) $fields['twitter_image']) : '';

        $count += $this->updateOrDeletePostMeta($postId, MetaKeys::TITLE, $title);
        $count += $this->updateOrDeletePostMeta($postId, MetaKeys::DESCRIPTION, $desc);
        $count += $this->updateOrDeletePostMeta($postId, MetaKeys::CANONICAL, $canon);

        // Robots tri-state: default => delete
        if ($rIndex !== '' && $rIndex !== 'default') {
            update_post_meta($postId, MetaKeys::ROBOTS_INDEX, $rIndex);
            $count++;
        } else {
            if (delete_post_meta($postId, MetaKeys::ROBOTS_INDEX)) {
                $count++;
            }
        }

        if ($rFollow !== '' && $rFollow !== 'default') {
            update_post_meta($postId, MetaKeys::ROBOTS_FOLLOW, $rFollow);
            $count++;
        } else {
            if (delete_post_meta($postId, MetaKeys::ROBOTS_FOLLOW)) {
                $count++;
            }
        }

        $count += $this->updateOrDeletePostMeta($postId, MetaKeys::OG_TITLE, $ogTitle);
        $count += $this->updateOrDeletePostMeta($postId, MetaKeys::OG_DESCRIPTION, $ogDesc);
        $count += $this->updateOrDeletePostMeta($postId, MetaKeys::OG_IMAGE, $ogImg);

        $count += $this->updateOrDeletePostMeta($postId, MetaKeys::TWITTER_TITLE, $twTitle);
        $count += $this->updateOrDeletePostMeta($postId, MetaKeys::TWITTER_DESCRIPTION, $twDesc);
        $count += $this->updateOrDeletePostMeta($postId, MetaKeys::TWITTER_IMAGE, $twImg);

        // Internal event for caches/audit.
        $this->container->get('events')->dispatch('seo/meta/updated', [$postId, 'post']);

        return $count;
    }

    private function updateOrDeletePostMeta(int $postId, string $metaKey, string $value): int
    {
        $value = trim($value);
        if ($value !== '') {
            update_post_meta($postId, $metaKey, $value);
            return 1;
        }
        return delete_post_meta($postId, $metaKey) ? 1 : 0;
    }

    private function sanitizeRobotsIndex(string $value): string
    {
        $value = trim($value);
        $allowed = ['default', 'index', 'noindex', ''];
        return in_array($value, $allowed, true) ? $value : '';
    }

    private function sanitizeRobotsFollow(string $value): string
    {
        $value = trim($value);
        $allowed = ['default', 'follow', 'nofollow', ''];
        return in_array($value, $allowed, true) ? $value : '';
    }

    private function sanitizeUrlAllowRelative(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }

        if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
            $url = home_url($url);
        }

        return esc_url_raw($url);
    }

    private function sanitizeImageUrlAllowRelative(string $url): string
    {
        return $this->sanitizeUrlAllowRelative($url);
    }

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

        $filters = $this->currentPostFilters();

        // Fetch up to 5000 rows for export (enough for MVP; can be extended).
        $export = new BulkTableExporter($this->container);
        $csv = $export->buildCsv($filters, 5000);

        $filename = 'wpsn-bulk-meta-' . gmdate('Y-m-d-His') . '.csv';

        nocache_headers();
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename=' . $filename);
        echo $csv; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        exit;
    }

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

        $filters = $this->currentPostFilters();

        $includeEmpty = isset($_GET['include_empty_fields']) && (string) wp_unslash($_GET['include_empty_fields']) !== '';
        $includeEmpty = $includeEmpty && (string) wp_unslash($_GET['include_empty_fields']) !== '0';

        $export = new BulkTableJsonExporter($this->container);
        $json = $export->buildJson($filters, 5000, (bool) $includeEmpty);

        $filename = 'wpsn-overrides-posts-' . gmdate('Y-m-d-His') . ($includeEmpty ? '-snapshot' : '') . '.json';

        nocache_headers();
        header('Content-Type: application/json; charset=utf-8');
        header('Content-Disposition: attachment; filename=' . $filename);
        echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        exit;
    }

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

        $filters = $this->currentTermFilters();

        $export = new TermBulkTableExporter($this->container);
        $csv = $export->buildCsv($filters, 5000);

        $filename = 'wpsn-bulk-terms-' . gmdate('Y-m-d-His') . '.csv';

        nocache_headers();
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename=' . $filename);
        echo $csv; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        exit;
    }

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

        $filters = $this->currentTermFilters();

        $includeEmpty = isset($_GET['include_empty_fields']) && (string) wp_unslash($_GET['include_empty_fields']) !== '';
        $includeEmpty = $includeEmpty && (string) wp_unslash($_GET['include_empty_fields']) !== '0';

        $export = new TermBulkTableJsonExporter($this->container);
        $json = $export->buildJson($filters, 5000, (bool) $includeEmpty);

        $filename = 'wpsn-overrides-terms-' . gmdate('Y-m-d-His') . ($includeEmpty ? '-snapshot' : '') . '.json';

        nocache_headers();
        header('Content-Type: application/json; charset=utf-8');
        header('Content-Disposition: attachment; filename=' . $filename);
        echo $json; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        exit;
    }

    public function handleImportPreview(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_bulk_table_import')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        if (!isset($_FILES['csv']) || !is_array($_FILES['csv']) || !isset($_FILES['csv']['tmp_name'])) {
            $this->redirectWithNotice(__('No CSV file uploaded.', 'wp-seo-next'), 'error');
        }

        $tmp = (string) $_FILES['csv']['tmp_name'];
        if ($tmp === '' || !is_readable($tmp)) {
            $this->redirectWithNotice(__('Uploaded file is not readable.', 'wp-seo-next'), 'error');
        }

        $options = [
            'apply_only_changed' => isset($_POST['apply_only_changed']) && (string) wp_unslash($_POST['apply_only_changed']) === '1',
            'empty_cells' => isset($_POST['empty_cells']) ? sanitize_key((string) wp_unslash($_POST['empty_cells'])) : 'ignore',
            'clear_token' => isset($_POST['clear_token']) ? sanitize_text_field((string) wp_unslash($_POST['clear_token'])) : '__CLEAR__',
            'mapping' => isset($_POST['mapping']) ? sanitize_key((string) wp_unslash($_POST['mapping'])) : 'auto',
            'rewrite_rules' => isset($_POST['rewrite_rules']) ? sanitize_textarea_field((string) wp_unslash($_POST['rewrite_rules'])) : '',
        ];

        $importer = new BulkTableImporter($this->container);
        $result = $importer->parseAndDiff($tmp, $options);

        $token = wp_generate_password(12, false, false);
        set_transient($this->previewTransientKey($token, 'posts'), $result, 15 * MINUTE_IN_SECONDS);

        $this->redirectWithNotice(__('CSV parsed. Review the diff preview below.', 'wp-seo-next'), 'success', [
            'view' => 'posts',
            'import_preview' => $token,
        ]);
    }

    public function handleImportPreviewJson(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_bulk_table_import_json')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        if (!isset($_FILES['json']) || !is_array($_FILES['json']) || !isset($_FILES['json']['tmp_name'])) {
            $this->redirectWithNotice(__('No JSON file uploaded.', 'wp-seo-next'), 'error');
        }

        $tmp = (string) $_FILES['json']['tmp_name'];
        if ($tmp === '' || !is_readable($tmp)) {
            $this->redirectWithNotice(__('Uploaded file is not readable.', 'wp-seo-next'), 'error');
        }

        $options = [
            'apply_only_changed' => isset($_POST['apply_only_changed']) && (string) wp_unslash($_POST['apply_only_changed']) === '1',
            'empty_cells' => isset($_POST['empty_cells']) ? sanitize_key((string) wp_unslash($_POST['empty_cells'])) : 'ignore',
            'clear_token' => isset($_POST['clear_token']) ? sanitize_text_field((string) wp_unslash($_POST['clear_token'])) : '__CLEAR__',
            'mapping' => isset($_POST['mapping']) ? sanitize_key((string) wp_unslash($_POST['mapping'])) : 'auto',
            'rewrite_rules' => isset($_POST['rewrite_rules']) ? sanitize_textarea_field((string) wp_unslash($_POST['rewrite_rules'])) : '',
        ];

        $importer = new BulkTableJsonImporter($this->container);
        $result = $importer->parseAndDiff($tmp, $options);

        // Mark source (optional, can be used by UI later).
        $result['source'] = 'json';

        $token = wp_generate_password(12, false, false);
        set_transient($this->previewTransientKey($token, 'posts'), $result, 15 * MINUTE_IN_SECONDS);

        $this->redirectWithNotice(__('JSON parsed. Review the diff preview below.', 'wp-seo-next'), 'success', [
            'view' => 'posts',
            'import_preview' => $token,
        ]);
    }

    public function handleImportApply(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_bulk_table_import_apply')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $token = isset($_POST['token']) ? sanitize_key((string) wp_unslash($_POST['token'])) : '';
        if ($token === '') {
            $this->redirectWithNotice(__('Missing import token.', 'wp-seo-next'), 'error');
        }

        $data = get_transient($this->previewTransientKey($token, 'posts'));
        if (!is_array($data) || empty($data['ops']) || !is_array($data['ops'])) {
            $this->redirectWithNotice(__('Import preview expired or invalid. Please upload CSV again.', 'wp-seo-next'), 'error');
        }

        $ops = $data['ops'];
        $importer = new BulkTableImporter($this->container);
        $applied = $importer->applyOps($ops);

        delete_transient($this->previewTransientKey($token, 'posts'));

        $msg = sprintf(
            /* translators: %d: number of applied changes */
            __('Applied %d changes from CSV.', 'wp-seo-next'),
            (int) $applied
        );

        $this->redirectWithNotice($msg, 'success', ['view' => 'posts']);
    }

    /**
     * Queue-first apply for import previews (CSV/JSON).
     *
     * This is the recommended mode on shared hosting where admin-ajax may 503.
     */
    public function handleImportApplyQueue(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), self::QUEUE_NONCE_ACTION)) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $view = isset($_POST['view']) ? sanitize_key((string) wp_unslash($_POST['view'])) : 'posts';
        $view = in_array($view, ['posts', 'terms'], true) ? $view : 'posts';

        $token = isset($_POST['token']) ? sanitize_key((string) wp_unslash($_POST['token'])) : '';
        if ($token === '') {
            $this->redirectWithNotice(__('Missing import token.', 'wp-seo-next'), 'error', ['view' => $view]);
        }

        $previewKey = $this->previewTransientKey($token, $view);
        $preview = get_transient($previewKey);
        if (!is_array($preview) || empty($preview['ops']) || !is_array($preview['ops'])) {
            $this->redirectWithNotice(__('Import preview expired or invalid. Please upload again.', 'wp-seo-next'), 'error', ['view' => $view]);
        }

        $totalOps = count($preview['ops']);
        if ($totalOps <= 0) {
            $this->redirectWithNotice(__('Nothing to apply.', 'wp-seo-next'), 'info', ['view' => $view]);
        }

        // Extend preview TTL so the queue can finish on slow hosts.
        set_transient($previewKey, $preview, 6 * HOUR_IN_SECONDS);

        $sessionId = wp_generate_password(12, false, false);
        $uid = (int) get_current_user_id();

        $batchSize = (int) $this->settings->get('performance.bulk_table.import_batch', 80);
        if ($batchSize < 10) {
            $batchSize = 10;
        }
        if ($batchSize > 500) {
            $batchSize = 500;
        }

        $session = [
            'session_id' => $sessionId,
            'token' => $token,
            'view' => $view,
            'user_id' => $uid,
            'status' => 'queued',
            'offset' => 0,
            'total_ops' => $totalOps,
            'applied_fields' => 0,
            'batch_size' => $batchSize,
            'started_at' => time(),
            'updated_at' => time(),
            'last_error' => '',
        ];

        set_transient($this->queueSessionKey($sessionId), $session, 6 * HOUR_IN_SECONDS);
        set_transient($this->queueActiveKey($view), $sessionId, 6 * HOUR_IN_SECONDS);

        /** @var \WPSN\Core\Queue\QueueInterface $queue */
        $queue = $this->container->get('queue');
        $queue->enqueue(BulkTableJobs::JOB_IMPORT_APPLY, [
            'session_id' => $sessionId,
        ]);

        $queueMode = (string) $this->settings->get('performance.queue.mode', 'auto');
        if ($queueMode === 'direct') {
            $msg = __('Queue apply started. Direct mode will run jobs at the end of this request (no WP-Cron tick needed). Refresh later to see progress.', 'wp-seo-next');
        } else {
            $msg = __('Queue apply started. This will continue in the background via WP-Cron / server cron. Refresh later to see progress.', 'wp-seo-next');
        }
        $this->redirectWithNotice($msg, 'success', [
            'view' => $view,
            'import_preview' => $token,
        ]);
    }

    public function handleImportApplyQueueCancel(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), self::QUEUE_NONCE_ACTION)) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $view = isset($_POST['view']) ? sanitize_key((string) wp_unslash($_POST['view'])) : 'posts';
        $view = in_array($view, ['posts', 'terms'], true) ? $view : 'posts';

        $token = isset($_POST['token']) ? sanitize_key((string) wp_unslash($_POST['token'])) : '';

        $sessionId = isset($_POST['session_id']) ? sanitize_key((string) wp_unslash($_POST['session_id'])) : '';
        if ($sessionId === '') {
            $sid = get_transient($this->queueActiveKey($view));
            if (is_string($sid) && $sid !== '') {
                $sessionId = $sid;
            }
        }

        if ($sessionId === '') {
            $this->redirectWithNotice(__('No active import session to cancel.', 'wp-seo-next'), 'info', ['view' => $view]);
        }

        $key = $this->queueSessionKey($sessionId);
        $session = get_transient($key);
        if (is_array($session)) {
            $uid = (int) get_current_user_id();
            $owner = isset($session['user_id']) ? (int) $session['user_id'] : 0;
            if ($owner > 0 && $owner !== $uid) {
                wp_die(esc_html__('Permission denied.', 'wp-seo-next'));
            }

            $session['status'] = 'canceled';
            $session['updated_at'] = time();
            set_transient($key, $session, 6 * HOUR_IN_SECONDS);
        }

        delete_transient($this->queueActiveKey($view));

        $args = ['view' => $view];
        if ($token !== '') {
            $args['import_preview'] = $token;
        }
        $this->redirectWithNotice(__('Canceled. Pending jobs will stop automatically.', 'wp-seo-next'), 'success', $args);
    }

    public function handleImportApplyQueueWorker(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), self::QUEUE_NONCE_ACTION)) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $view = isset($_POST['view']) ? sanitize_key((string) wp_unslash($_POST['view'])) : 'posts';
        $view = in_array($view, ['posts', 'terms'], true) ? $view : 'posts';

        $token = isset($_POST['token']) ? sanitize_key((string) wp_unslash($_POST['token'])) : '';

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

        /** @var QueueRunner $runner */
        $runner = $this->container->get('queue_runner');
        $processed = 0;
        try {
            $processed = $runner->runOnlyTypes([BulkTableJobs::JOB_IMPORT_APPLY], $max);
        } catch (\Throwable $e) {
            $this->logger->error('Bulk Table import worker failed: ' . $e->getMessage());
            $this->redirectWithNotice(__('Worker failed. Please check logs.', 'wp-seo-next'), 'error', ['view' => $view]);
        }

        $msg = sprintf(
            /* translators: %d: processed jobs */
            __('Worker processed %d job(s).', 'wp-seo-next'),
            (int) $processed
        );
        $args = ['view' => $view];
        if ($token !== '') {
            $args['import_preview'] = $token;
        }
        $this->redirectWithNotice($msg, 'success', $args);
    }

    public function handleImportPreviewTerms(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_bulk_table_terms_import')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        if (!isset($_FILES['csv']) || !is_array($_FILES['csv']) || !isset($_FILES['csv']['tmp_name'])) {
            $this->redirectWithNotice(__('No CSV file uploaded.', 'wp-seo-next'), 'error', ['view' => 'terms']);
        }

        $tmp = (string) $_FILES['csv']['tmp_name'];
        if ($tmp === '' || !is_readable($tmp)) {
            $this->redirectWithNotice(__('Uploaded file is not readable.', 'wp-seo-next'), 'error', ['view' => 'terms']);
        }

        $options = [
            'apply_only_changed' => isset($_POST['apply_only_changed']) && (string) wp_unslash($_POST['apply_only_changed']) === '1',
            'empty_cells' => isset($_POST['empty_cells']) ? sanitize_key((string) wp_unslash($_POST['empty_cells'])) : 'ignore',
            'clear_token' => isset($_POST['clear_token']) ? sanitize_text_field((string) wp_unslash($_POST['clear_token'])) : '__CLEAR__',
            'mapping' => isset($_POST['mapping']) ? sanitize_key((string) wp_unslash($_POST['mapping'])) : 'auto',
        ];

        $importer = new TermBulkTableImporter($this->container);
        $result = $importer->parseAndDiff($tmp, $options);

        $token = wp_generate_password(12, false, false);
        set_transient($this->previewTransientKey($token, 'terms'), $result, 15 * MINUTE_IN_SECONDS);

        $this->redirectWithNotice(__('CSV parsed. Review the diff preview below.', 'wp-seo-next'), 'success', [
            'view' => 'terms',
            'import_preview' => $token,
        ]);
    }

    public function handleImportPreviewJsonTerms(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_bulk_table_terms_import_json')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        if (!isset($_FILES['json']) || !is_array($_FILES['json']) || !isset($_FILES['json']['tmp_name'])) {
            $this->redirectWithNotice(__('No JSON file uploaded.', 'wp-seo-next'), 'error', ['view' => 'terms']);
        }

        $tmp = (string) $_FILES['json']['tmp_name'];
        if ($tmp === '' || !is_readable($tmp)) {
            $this->redirectWithNotice(__('Uploaded file is not readable.', 'wp-seo-next'), 'error', ['view' => 'terms']);
        }

        $options = [
            'apply_only_changed' => isset($_POST['apply_only_changed']) && (string) wp_unslash($_POST['apply_only_changed']) === '1',
            'empty_cells' => isset($_POST['empty_cells']) ? sanitize_key((string) wp_unslash($_POST['empty_cells'])) : 'ignore',
            'clear_token' => isset($_POST['clear_token']) ? sanitize_text_field((string) wp_unslash($_POST['clear_token'])) : '__CLEAR__',
            'mapping' => isset($_POST['mapping']) ? sanitize_key((string) wp_unslash($_POST['mapping'])) : 'auto',
        ];

        $importer = new TermBulkTableJsonImporter($this->container);
        $result = $importer->parseAndDiff($tmp, $options);
        $result['source'] = 'json';

        $token = wp_generate_password(12, false, false);
        set_transient($this->previewTransientKey($token, 'terms'), $result, 15 * MINUTE_IN_SECONDS);

        $this->redirectWithNotice(__('JSON parsed. Review the diff preview below.', 'wp-seo-next'), 'success', [
            'view' => 'terms',
            'import_preview' => $token,
        ]);
    }

    /**
     * AJAX: start a batch apply session.
     */
    public function ajaxImportApplyStart(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => __('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::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => __('Invalid nonce.', 'wp-seo-next')], 400);
        }

        $view = isset($_POST['view']) ? sanitize_key((string) wp_unslash($_POST['view'])) : 'posts';
        if (!in_array($view, ['posts', 'terms'], true)) {
            $view = 'posts';
        }

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

        $preview = get_transient($this->previewTransientKey($token, $view));
        if (!is_array($preview) || empty($preview['ops']) || !is_array($preview['ops'])) {
            wp_send_json_error(['message' => __('Import preview expired or invalid. Please upload again.', 'wp-seo-next')], 400);
        }

        $totalOps = count($preview['ops']);
        if ($totalOps === 0) {
            wp_send_json_error(['message' => __('Nothing to apply.', 'wp-seo-next')], 400);
        }

        $sessionId = wp_generate_password(12, false, false);
        $session = [
            'token' => $token,
            'view' => $view,
            'offset' => 0,
            'applied_fields' => 0,
            'started_at' => time(),
            'batch_size' => 25,
            'total_ops' => $totalOps,
        ];

        set_transient($this->applySessionKey($sessionId, $view), $session, 30 * MINUTE_IN_SECONDS);

        wp_send_json_success([
            'session_id' => $sessionId,
            'total' => $totalOps,
        ]);
    }

    /**
     * AJAX: apply next batch.
     */
    public function ajaxImportApplyStep(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => __('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::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => __('Invalid nonce.', 'wp-seo-next')], 400);
        }

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

        // View is stored in session; but keep it as a fallback.
        $fallbackView = isset($_POST['view']) ? sanitize_key((string) wp_unslash($_POST['view'])) : 'posts';
        if (!in_array($fallbackView, ['posts', 'terms'], true)) {
            $fallbackView = 'posts';
        }

        $sessionKey = $this->applySessionKey($sessionId, $fallbackView);
        $session = get_transient($sessionKey);
        if (!is_array($session)) {
            // Try both views in case caller did not send view.
            $session = get_transient($this->applySessionKey($sessionId, 'posts'));
            $sessionKey = $this->applySessionKey($sessionId, 'posts');
            if (!is_array($session)) {
                $session = get_transient($this->applySessionKey($sessionId, 'terms'));
                $sessionKey = $this->applySessionKey($sessionId, 'terms');
            }
        }

        if (!is_array($session) || empty($session['token']) || empty($session['view'])) {
            wp_send_json_error(['message' => __('Session expired. Please start again.', 'wp-seo-next')], 400);
        }

        $view = (string) $session['view'];
        $token = (string) $session['token'];
        $offset = isset($session['offset']) ? (int) $session['offset'] : 0;
        $appliedFields = isset($session['applied_fields']) ? (int) $session['applied_fields'] : 0;
        $batchSize = isset($session['batch_size']) ? (int) $session['batch_size'] : 25;
        if ($batchSize < 1) {
            $batchSize = 25;
        }
        if ($batchSize > 200) {
            $batchSize = 200;
        }

        $preview = get_transient($this->previewTransientKey($token, $view));
        if (!is_array($preview) || empty($preview['ops']) || !is_array($preview['ops'])) {
            delete_transient($sessionKey);
            wp_send_json_error(['message' => __('Import preview expired or invalid. Please upload again.', 'wp-seo-next')], 400);
        }

        $ops = $preview['ops'];
        $totalOps = count($ops);

        if ($offset >= $totalOps) {
            // Done (shouldn’t happen often).
            delete_transient($sessionKey);
            delete_transient($this->previewTransientKey($token, $view));
            wp_send_json_success([
                'processed' => $totalOps,
                'total' => $totalOps,
                'applied_fields' => $appliedFields,
                'done' => true,
                'redirect' => $this->pageUrl(['view' => $view]),
            ]);
        }

        $batch = array_slice($ops, $offset, $batchSize);

        if ($view === 'terms') {
            $importer = new TermBulkTableImporter($this->container);
        } else {
            $importer = new BulkTableImporter($this->container);
        }

        $fieldsThisStep = $importer->applyOps($batch);
        $appliedFields += (int) $fieldsThisStep;

        $offset += count($batch);

        // Persist session.
        $session['offset'] = $offset;
        $session['applied_fields'] = $appliedFields;
        set_transient($sessionKey, $session, 30 * MINUTE_IN_SECONDS);

        $done = $offset >= $totalOps;
        if ($done) {
            delete_transient($sessionKey);
            delete_transient($this->previewTransientKey($token, $view));
        }

        $redirect = '';
        if ($done) {
            $msg = sprintf(
                /* translators: %d: number of updated fields */
                __('Applied %d changes from import.', 'wp-seo-next'),
                (int) $appliedFields
            );
            $redirect = $this->pageUrl([
                'view' => $view,
                'wpsn_notice' => rawurlencode($msg),
                'wpsn_notice_type' => 'success',
            ]);
        }

        wp_send_json_success([
            'processed' => $offset,
            'total' => $totalOps,
            'applied_fields' => $appliedFields,
            'done' => $done,
            'redirect' => $redirect,
        ]);
    }

    public function ajaxImportApplyCancel(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => __('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::AJAX_NONCE_ACTION)) {
            wp_send_json_error(['message' => __('Invalid nonce.', 'wp-seo-next')], 400);
        }

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

        // Try delete both possible session keys.
        delete_transient($this->applySessionKey($sessionId, 'posts'));
        delete_transient($this->applySessionKey($sessionId, 'terms'));

        wp_send_json_success(['message' => __('Canceled.', 'wp-seo-next')]);
    }

    private function applySessionKey(string $sessionId, string $view): string
    {
        $uid = (int) get_current_user_id();
        return 'wpsn_bulk_apply_' . $view . '_' . $uid . '_' . sanitize_key($sessionId);
    }

    public function handleImportApplyTerms(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }
        if (!isset($_POST['wpsn_nonce']) || !wp_verify_nonce((string) wp_unslash($_POST['wpsn_nonce']), 'wpsn_bulk_table_terms_import_apply')) {
            wp_die(esc_html__('Invalid nonce.', 'wp-seo-next'));
        }

        $token = isset($_POST['token']) ? sanitize_key((string) wp_unslash($_POST['token'])) : '';
        if ($token === '') {
            $this->redirectWithNotice(__('Missing import token.', 'wp-seo-next'), 'error', ['view' => 'terms']);
        }

        $data = get_transient($this->previewTransientKey($token, 'terms'));
        if (!is_array($data) || empty($data['ops']) || !is_array($data['ops'])) {
            $this->redirectWithNotice(__('Import preview expired or invalid. Please upload CSV again.', 'wp-seo-next'), 'error', ['view' => 'terms']);
        }

        $ops = $data['ops'];
        $importer = new TermBulkTableImporter($this->container);
        $applied = $importer->applyOps($ops);

        delete_transient($this->previewTransientKey($token, 'terms'));

        $msg = sprintf(
            /* translators: %d: number of applied changes */
            __('Applied %d changes from CSV.', 'wp-seo-next'),
            (int) $applied
        );

        $this->redirectWithNotice($msg, 'success', ['view' => 'terms']);
    }
}
