<?php

namespace WPSN\Modules\Media\Admin;

use WPSN\Core\Logger;
use WPSN\Core\Queue\QueueInterface;
use WPSN\Core\Queue\QueueRunner;
use WPSN\Core\Settings;
use WPSN\Modules\Media\MediaJobs;
use WPSN\Modules\Media\MediaFilenameJobs;
use WPSN\Modules\Media\FilenameCsvHistoryRepository;
use WPSN\Core\Capabilities;

defined('ABSPATH') || exit;

/**
 * Tools → SEO Media: bulk generate/apply image ALT drafts.
 */
final class MediaAdmin
{
    public const PAGE_SLUG = 'wpsn-seo-media';
    public const AJAX_NONCE_ACTION = 'wpsn_media_ajax';

    /**
     * Base admin page slug for rendering links (Tools page by default).
     * When embedded (e.g. in the Bulk table page), we override this.
     */
    private string $basePageSlug;

    /**
     * Extra args that must always be present in links for this screen.
     * Example: ['view' => 'media'] when embedded in Tools → SEO Bulk Editor.
     *
     * @var array<string,string>
     */
    private array $basePageArgs;

    /**
     * Prefix for query parameters to avoid collisions when embedded.
     * Example: 'media_' gives media_view/media_s/media_mode.
     */
    private string $paramPrefix;

    private QueueInterface $queue;
    private QueueRunner $runner;
    private Settings $settings;
    private Logger $logger;

    /**
     * @param array<string,string> $basePageArgs
     */
    public function __construct(
        QueueInterface $queue,
        QueueRunner $runner,
        Settings $settings,
        Logger $logger,
        string $basePageSlug = self::PAGE_SLUG,
        array $basePageArgs = [],
        string $paramPrefix = ''
    ) {
        $this->queue = $queue;
        $this->runner = $runner;
        $this->settings = $settings;
        $this->logger = $logger;

        $this->basePageSlug = $basePageSlug !== '' ? $basePageSlug : self::PAGE_SLUG;
        $this->basePageArgs = $basePageArgs;
        $this->paramPrefix = $paramPrefix;
    }

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

        // Single-item actions.
        add_action('admin_post_wpsn_media_apply_single', [$this, 'handleApplySingle']);
        add_action('admin_post_wpsn_media_undo_single', [$this, 'handleUndoSingle']);

        // Single-item actions (filenames).
        add_action('admin_post_wpsn_media_filename_apply_single', [$this, 'handleFilenameApplySingle']);
        add_action('admin_post_wpsn_media_filename_undo_single', [$this, 'handleFilenameUndoSingle']);

        // Filename CSV export/import (rename drafts).
        add_action('admin_post_wpsn_media_filename_export_csv', [$this, 'handleFilenameExportCsv']);
        add_action('admin_post_wpsn_media_filename_import_preview', [$this, 'handleFilenameImportPreview']);
        add_action('admin_post_wpsn_media_filename_import_apply', [$this, 'handleFilenameImportApply']);

        // Filename CSV export/import (rollback / undo list).
        add_action('admin_post_wpsn_media_filename_rollback_export_csv', [$this, 'handleFilenameRollbackExportCsv']);
        add_action('admin_post_wpsn_media_filename_rollback_import_preview', [$this, 'handleFilenameRollbackImportPreview']);
        add_action('admin_post_wpsn_media_filename_rollback_import_apply', [$this, 'handleFilenameRollbackImportApply']);

        // Filename CSV history.
        add_action('admin_post_wpsn_media_filename_csv_history_clear', [$this, 'handleFilenameCsvHistoryClear']);

        // Queue-first (non-AJAX) bulk actions.
        add_action('admin_post_wpsn_media_queue_generate', [$this, 'handleQueueGenerate']);
        add_action('admin_post_wpsn_media_queue_apply_all', [$this, 'handleQueueApplyAll']);

        add_action('admin_post_wpsn_media_filename_queue_generate', [$this, 'handleFilenameQueueGenerate']);
        add_action('admin_post_wpsn_media_filename_queue_apply_all', [$this, 'handleFilenameQueueApplyAll']);
        add_action('admin_post_wpsn_media_filename_queue_clear_drafts', [$this, 'handleFilenameQueueClearDrafts']);

        // AJAX: generate now.
        add_action('wp_ajax_wpsn_media_generate_start', [$this, 'ajaxGenerateStart']);
        add_action('wp_ajax_wpsn_media_generate_step', [$this, 'ajaxGenerateStep']);
        add_action('wp_ajax_wpsn_media_generate_cancel', [$this, 'ajaxGenerateCancel']);

        // AJAX: generate filenames.
        add_action('wp_ajax_wpsn_media_filename_generate_start', [$this, 'ajaxFilenameGenerateStart']);
        add_action('wp_ajax_wpsn_media_filename_generate_step', [$this, 'ajaxFilenameGenerateStep']);
        add_action('wp_ajax_wpsn_media_filename_generate_cancel', [$this, 'ajaxFilenameGenerateCancel']);

        // AJAX: apply all.
        add_action('wp_ajax_wpsn_media_apply_all_start', [$this, 'ajaxApplyAllStart']);
        add_action('wp_ajax_wpsn_media_apply_all_step', [$this, 'ajaxApplyAllStep']);
        add_action('wp_ajax_wpsn_media_apply_all_cancel', [$this, 'ajaxApplyAllCancel']);

        // AJAX: apply all filenames.
        add_action('wp_ajax_wpsn_media_filename_apply_all_start', [$this, 'ajaxFilenameApplyAllStart']);
        add_action('wp_ajax_wpsn_media_filename_apply_all_step', [$this, 'ajaxFilenameApplyAllStep']);
        add_action('wp_ajax_wpsn_media_filename_apply_all_cancel', [$this, 'ajaxFilenameApplyAllCancel']);

        // AJAX: clear filename drafts.
        add_action('wp_ajax_wpsn_media_filename_clear_drafts_start', [$this, 'ajaxFilenameClearDraftsStart']);
        add_action('wp_ajax_wpsn_media_filename_clear_drafts_step', [$this, 'ajaxFilenameClearDraftsStep']);
        add_action('wp_ajax_wpsn_media_filename_clear_drafts_cancel', [$this, 'ajaxFilenameClearDraftsCancel']);
    }

    public function adminMenu(): void
    {
        add_submenu_page(
            'tools.php',
            __('SEO Media', 'wp-seo-next'),
            __('SEO Media', 'wp-seo-next'),
            Capabilities::CAP_TOOLS,
            self::PAGE_SLUG,
            [$this, 'renderPage']
        );
    }

    public function enqueueAssets(string $hook): void
    {
        if ($hook !== 'tools_page_' . self::PAGE_SLUG) {
            return;
        }

        wp_enqueue_style('wpsn-media-admin', plugins_url('assets/admin/media.css', WPSN_PLUGIN_FILE), [], WPSN_VERSION);
        wp_enqueue_script('wpsn-media-admin', plugins_url('assets/admin/media.js', WPSN_PLUGIN_FILE), [], WPSN_VERSION, true);

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

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

        ?>
        <div class="wrap">
            <h1><?php echo esc_html__('SEO Media', 'wp-seo-next'); ?></h1>
            <?php $this->renderScreen(false); ?>
        </div>
        <?php
    }

    /**
     * Render the UI without the outer "wrap" and H1 (for embedding in other admin screens).
     */
    public function renderEmbedded(string $heading = ''): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            echo '<p>' . esc_html__('You do not have permission to access this section.', 'wp-seo-next') . '</p>';
            return;
        }

        if ($heading !== '') {
            echo '<h2 style="margin-top: 10px;">' . esc_html($heading) . '</h2>';
        }
        $this->renderScreen(true);
    }

    private function renderScreen(bool $embedded): void
    {
        $modeKey = $this->q('mode');
        $mode = isset($_GET[$modeKey]) ? sanitize_key((string) wp_unslash($_GET[$modeKey])) : 'alt';
        if (!in_array($mode, ['alt', 'filenames'], true)) {
            $mode = 'alt';
        }

        $viewKey = $this->q('view');
        $view = isset($_GET[$viewKey]) ? sanitize_key((string) wp_unslash($_GET[$viewKey])) : 'draft';
        $allowedViews = ($mode === 'filenames') ? ['draft', 'applied', 'csv_history'] : ['draft', 'applied'];
        if (!in_array($view, $allowedViews, true)) {
            $view = 'draft';
        }

        $searchKey = $this->q('s');
        $search = isset($_GET[$searchKey]) ? sanitize_text_field((string) wp_unslash($_GET[$searchKey])) : '';

        $sourceKey = $this->q('source');
        $sourceFilter = isset($_GET[$sourceKey]) ? sanitize_key((string) wp_unslash($_GET[$sourceKey])) : '';
        $allowedSources = ($mode === 'filenames') ? ['', 'local'] : ['', 'local', 'openai'];
        if (!in_array($sourceFilter, $allowedSources, true)) {
            $sourceFilter = '';
        }

        $perPageKey = $this->q('per_page');
        $perPage = isset($_GET[$perPageKey]) ? (int) $_GET[$perPageKey] : 50;
        $perPage = max(20, min(200, $perPage));

        $pagedKey = $this->q('paged');
        $paged = isset($_GET[$pagedKey]) ? (int) $_GET[$pagedKey] : 1;
        $paged = max(1, $paged);

        $metaStatusKey = ($mode === 'filenames') ? MediaFilenameJobs::META_STATUS : MediaJobs::META_STATUS;
        $metaSourceKey = ($mode === 'filenames') ? MediaFilenameJobs::META_SOURCE : MediaJobs::META_SOURCE;

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

        $this->renderNotices();
        $this->renderModeTabs($mode);
        $this->renderStatusTabs($mode, $view, $draftCount, $appliedCount);

        if ($mode === 'filenames') {
            echo '<p style="max-width:1000px;">' . esc_html__('Generate safe draft rename suggestions for image filenames and apply them with rollback support.', 'wp-seo-next') . '</p>';

            if ($view === 'csv_history') {
                $this->renderFilenameCsvHistory();
                return;
            }

            $this->renderFilenameQueueGenerateBox();

            echo '<details class="wpsn-ajax-optional" style="max-width:1200px; margin:10px 0 0 0;"><summary>' . esc_html__('AJAX mode (optional)', 'wp-seo-next') . '</summary>';
            $this->renderFilenameGenerateBox();
            echo '</details>';

            if ($view === 'draft') {
                $this->renderFilenameQueueApplyAllBox();
                $this->renderFilenameQueueClearDraftsBox();

                echo '<details class="wpsn-ajax-optional" style="max-width:1200px; margin:10px 0 0 0;"><summary>' . esc_html__('AJAX mode (optional)', 'wp-seo-next') . '</summary>';
                $this->renderFilenameApplyAllBox();
                $this->renderFilenameClearDraftsBox();
                echo '</details>';
            }
            $this->renderFilenameCsvBox($view, $search, $sourceFilter);
            if ($view === 'applied') {
                $this->renderFilenameRollbackCsvBox($search, $sourceFilter);
            }
            $this->renderFilenameFilters($view, $mode, $search, $sourceFilter, $perPage);
            $q = $this->buildSuggestionsQuery($view, $search, $sourceFilter, $perPage, $paged, false, $metaStatusKey, $metaSourceKey);
            $this->renderFilenameSuggestionsTable($q, $view);
            $this->renderPagination((int) $q->max_num_pages, $paged, $view, $mode, $search, $sourceFilter, $perPage);
        } else {
            echo '<p style="max-width:1000px;">' . esc_html__('Generate and review image ALT text drafts (local rules or OpenAI) and apply them safely with undo history.', 'wp-seo-next') . '</p>';
            $this->renderQueueGenerateBox();

            echo '<details class="wpsn-ajax-optional" style="max-width:1200px; margin:10px 0 0 0;"><summary>' . esc_html__('AJAX mode (optional)', 'wp-seo-next') . '</summary>';
            $this->renderGenerateBox();
            echo '</details>';

            if ($view === 'draft') {
                $this->renderQueueApplyAllBox();

                echo '<details class="wpsn-ajax-optional" style="max-width:1200px; margin:10px 0 0 0;"><summary>' . esc_html__('AJAX mode (optional)', 'wp-seo-next') . '</summary>';
                $this->renderApplyAllBox();
                echo '</details>';
            }
            $this->renderFilters($view, $mode, $search, $sourceFilter, $perPage);
            $q = $this->buildSuggestionsQuery($view, $search, $sourceFilter, $perPage, $paged, false, $metaStatusKey, $metaSourceKey);
            $this->renderSuggestionsTable($q, $view);
            $this->renderPagination((int) $q->max_num_pages, $paged, $view, $mode, $search, $sourceFilter, $perPage);
        }
    }

    private function renderModeTabs(string $mode): void
    {
        $modeKey = $this->q('mode');
        $viewKey = $this->q('view');
        $currentView = isset($_GET[$viewKey]) ? sanitize_key((string) wp_unslash($_GET[$viewKey])) : 'draft';
        if (!in_array($currentView, ['draft', 'applied'], true)) {
            $currentView = 'draft';
        }

        ?>
        <h2 class="nav-tab-wrapper" style="margin-top:10px;">
            <a class="nav-tab <?php echo $mode === 'alt' ? 'nav-tab-active' : ''; ?>" href="<?php echo esc_url($this->pageUrl([$modeKey => 'alt', $viewKey => $currentView])); ?>">
                <?php echo esc_html__('ALT Text', 'wp-seo-next'); ?>
            </a>
            <a class="nav-tab <?php echo $mode === 'filenames' ? 'nav-tab-active' : ''; ?>" href="<?php echo esc_url($this->pageUrl([$modeKey => 'filenames', $viewKey => $currentView])); ?>">
                <?php echo esc_html__('Filenames', 'wp-seo-next'); ?>
            </a>
        </h2>
        <?php
    }

    private function renderStatusTabs(string $mode, string $view, int $draftCount, int $appliedCount): void
    {
        $modeKey = $this->q('mode');
        $viewKey = $this->q('view');

        ?>
        <h2 class="nav-tab-wrapper" style="margin-top:10px;">
            <a class="nav-tab <?php echo $view === 'draft' ? 'nav-tab-active' : ''; ?>" href="<?php echo esc_url($this->pageUrl([$modeKey => $mode, $viewKey => 'draft'])); ?>">
                <?php echo esc_html(sprintf(__('Drafts (%d)', 'wp-seo-next'), $draftCount)); ?>
            </a>
            <a class="nav-tab <?php echo $view === 'applied' ? 'nav-tab-active' : ''; ?>" href="<?php echo esc_url($this->pageUrl([$modeKey => $mode, $viewKey => 'applied'])); ?>">
                <?php echo esc_html(sprintf(__('Applied (%d)', 'wp-seo-next'), $appliedCount)); ?>
            </a>
            <?php if ($mode === 'filenames') : ?>
                <a class="nav-tab <?php echo $view === 'csv_history' ? 'nav-tab-active' : ''; ?>" href="<?php echo esc_url($this->pageUrl([$modeKey => $mode, $viewKey => 'csv_history'])); ?>">
                    <?php echo esc_html__('Filename CSV history', 'wp-seo-next'); ?>
                </a>
            <?php endif; ?>
            <a class="nav-tab" href="<?php echo esc_url(admin_url('tools.php?page=wpsn-seo-bulk&view=failed')); ?>">
                <?php echo esc_html__('Queue Failed Jobs', 'wp-seo-next'); ?>
            </a>
        </h2>
        <?php
    }

    private function renderQueueGenerateBox(): void
    {
        $localEnabled = (bool) $this->settings->get('ai.local.enabled', true);
        $openaiEnabled = (bool) $this->settings->get('ai.external.enabled', false);

        $queueUrl = admin_url('tools.php?page=wpsn-queue');

        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Generate ALT drafts (Queue-first)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('Recommended on shared hosting and on servers where admin-ajax is blocked. This enqueues generation jobs into the internal queue.', 'wp-seo-next'); ?></p>
                <p class="description">
                    <?php echo esc_html__('Tip: after queueing, open Queue Center to run worker if WP-Cron is not firing.', 'wp-seo-next'); ?>
                    <a href="<?php echo esc_url($queueUrl); ?>"><?php echo esc_html__('Open Queue Center', 'wp-seo-next'); ?></a>
                </p>

                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                    <?php wp_nonce_field('wpsn_media_queue_generate', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_media_queue_generate" />

                    <table class="form-table" role="presentation">
                        <tr>
                            <th scope="row"><label for="wpsn-media-queue-source"><?php echo esc_html__('Source', 'wp-seo-next'); ?></label></th>
                            <td>
                                <select id="wpsn-media-queue-source" name="source">
                                    <option value="local" <?php selected(true, true); ?> <?php disabled(!$localEnabled); ?>><?php echo esc_html__('Local (rules)', 'wp-seo-next'); ?></option>
                                    <option value="openai" <?php disabled(!$openaiEnabled); ?>><?php echo esc_html__('OpenAI', 'wp-seo-next'); ?></option>
                                </select>
                                <?php if (!$localEnabled) : ?>
                                    <p class="description" style="color:#b32d2e;"><?php echo esc_html__('Local suggestions are disabled in Settings → AI.', 'wp-seo-next'); ?></p>
                                <?php endif; ?>
                                <?php if (!$openaiEnabled) : ?>
                                    <p class="description"><?php echo esc_html__('Enable OpenAI in Settings → AI and add an API key to use this option.', 'wp-seo-next'); ?></p>
                                <?php endif; ?>
                            </td>
                        </tr>
                        <tr>
                            <th scope="row"><?php echo esc_html__('Filters', 'wp-seo-next'); ?></th>
                            <td>
                                <label><input type="checkbox" name="missing_alt_only" value="1" checked /> <?php echo esc_html__('Only images with empty ALT', 'wp-seo-next'); ?></label><br />
                                <label><input type="checkbox" name="only_if_empty" value="1" checked /> <?php echo esc_html__('Only generate if current ALT is empty (safety)', 'wp-seo-next'); ?></label><br />
                                <label><input type="checkbox" name="overwrite_suggestions" value="1" /> <?php echo esc_html__('Overwrite existing drafts', 'wp-seo-next'); ?></label>
                            </td>
                        </tr>
                        <tr>
                            <th scope="row"><label for="wpsn-media-queue-max-len"><?php echo esc_html__('Max ALT length', 'wp-seo-next'); ?></label></th>
                            <td>
                                <input type="number" id="wpsn-media-queue-max-len" name="max_length" value="125" min="20" max="300" style="width:120px;" />
                            </td>
                        </tr>
                        <tr>
                            <th scope="row"><?php echo esc_html__('OpenAI options', 'wp-seo-next'); ?></th>
                            <td>
                                <label><input type="checkbox" name="use_vision" value="1" /> <?php echo esc_html__('Include image URL (vision) for better accuracy', 'wp-seo-next'); ?></label>
                                <p class="description"><?php echo esc_html__('If enabled, the attachment URL will be sent to OpenAI. Make sure the URL is accessible and contains no sensitive content.', 'wp-seo-next'); ?></p>

                                <label for="wpsn-media-queue-vision-detail"><?php echo esc_html__('Vision detail', 'wp-seo-next'); ?></label>
                                <select id="wpsn-media-queue-vision-detail" name="vision_detail">
                                    <option value="low" selected><?php echo esc_html__('Low (faster/cheaper)', 'wp-seo-next'); ?></option>
                                    <option value="auto"><?php echo esc_html__('Auto', 'wp-seo-next'); ?></option>
                                    <option value="high"><?php echo esc_html__('High (slower)', 'wp-seo-next'); ?></option>
                                </select>
                            </td>
                        </tr>
                        <tr>
                            <th scope="row"><label for="wpsn-media-queue-limit"><?php echo esc_html__('Limit', 'wp-seo-next'); ?></label></th>
                            <td>
                                <input type="number" id="wpsn-media-queue-limit" name="limit" value="500" min="1" max="20000" style="width:120px;" />
                                <p class="description"><?php echo esc_html__('How many attachments to enqueue in this run.', 'wp-seo-next'); ?></p>
                            </td>
                        </tr>
                    </table>

                    <p>
                        <button class="button button-primary"><?php echo esc_html__('Queue generation jobs', 'wp-seo-next'); ?></button>
                    </p>
                </form>
            </div>
        </div>
        <?php
    }

    private function renderQueueApplyAllBox(): void
    {
        $queueUrl = admin_url('tools.php?page=wpsn-queue');

        $searchKey = $this->q('s');
        $search = isset($_GET[$searchKey]) ? sanitize_text_field((string) wp_unslash($_GET[$searchKey])) : '';

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

        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Apply all drafts (Queue-first)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('Queues apply jobs for draft ALT suggestions (matching current filters).', 'wp-seo-next'); ?></p>
                <p class="description">
                    <?php echo esc_html__('After queueing, open Queue Center to run worker if needed.', 'wp-seo-next'); ?>
                    <a href="<?php echo esc_url($queueUrl); ?>"><?php echo esc_html__('Open Queue Center', 'wp-seo-next'); ?></a>
                </p>

                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                    <?php wp_nonce_field('wpsn_media_queue_apply_all', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_media_queue_apply_all" />
                    <input type="hidden" name="search" value="<?php echo esc_attr($search); ?>" />
                    <input type="hidden" name="source_filter" value="<?php echo esc_attr($sourceFilter); ?>" />

                    <p>
                        <label><input type="checkbox" name="apply_only_changed" value="1" checked /> <?php echo esc_html__('Apply only changed (avoid unnecessary writes)', 'wp-seo-next'); ?></label>
                    </p>

                    <p>
                        <label for="wpsn-media-queue-apply-limit"><?php echo esc_html__('Limit (current filters)', 'wp-seo-next'); ?></label>
                        <input type="number" id="wpsn-media-queue-apply-limit" name="limit" value="500" min="1" max="20000" style="width:120px;" />
                    </p>

                    <p>
                        <button class="button button-primary"><?php echo esc_html__('Queue apply jobs', 'wp-seo-next'); ?></button>
                    </p>
                </form>
            </div>
        </div>
        <?php
    }

    private function renderFilenameQueueGenerateBox(): void
    {
        $queueUrl = admin_url('tools.php?page=wpsn-queue');

        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Generate filename rename drafts (Queue-first)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('Queues filename draft generation jobs (recommended when admin-ajax is blocked/503).', 'wp-seo-next'); ?></p>
                <p class="description">
                    <?php echo esc_html__('After queueing, open Queue Center to run worker if needed.', 'wp-seo-next'); ?>
                    <a href="<?php echo esc_url($queueUrl); ?>"><?php echo esc_html__('Open Queue Center', 'wp-seo-next'); ?></a>
                </p>

                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                    <?php wp_nonce_field('wpsn_media_filename_queue_generate', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_media_filename_queue_generate" />

                    <table class="form-table" role="presentation">
                        <tr>
                            <th scope="row"><?php echo esc_html__('Filters', 'wp-seo-next'); ?></th>
                            <td>
                                <label><input type="checkbox" name="only_if_dirty" value="1" checked /> <?php echo esc_html__('Only images with "dirty" filenames (spaces/UPPERCASE/cyrillic + configured patterns)', 'wp-seo-next'); ?></label>
                                <p class="description" style="margin:6px 0 0 0;"><?php echo esc_html__('Configure patterns in Settings → Media SEO.', 'wp-seo-next'); ?></p>
                                <br />
                                <label><input type="checkbox" name="overwrite_suggestions" value="1" /> <?php echo esc_html__('Overwrite existing drafts', 'wp-seo-next'); ?></label>
                            </td>
                        </tr>
                        <tr>
                            <th scope="row"><label for="wpsn-media-fn-queue-max-base-len"><?php echo esc_html__('Max base length', 'wp-seo-next'); ?></label></th>
                            <td>
                                <input type="number" id="wpsn-media-fn-queue-max-base-len" name="max_base_len" value="80" min="20" max="120" style="width:120px;" />
                                <p class="description"><?php echo esc_html__('The filename base (without extension) will be trimmed to this length.', 'wp-seo-next'); ?></p>
                            </td>
                        </tr>
                        <tr>
                            <th scope="row"><label for="wpsn-media-fn-queue-limit"><?php echo esc_html__('Limit', 'wp-seo-next'); ?></label></th>
                            <td>
                                <input type="number" id="wpsn-media-fn-queue-limit" name="limit" value="500" min="1" max="20000" style="width:120px;" />
                                <p class="description"><?php echo esc_html__('How many attachments to enqueue in this run.', 'wp-seo-next'); ?></p>
                            </td>
                        </tr>
                    </table>

                    <p>
                        <button class="button button-primary"><?php echo esc_html__('Queue generation jobs', 'wp-seo-next'); ?></button>
                    </p>
                </form>
            </div>
        </div>
        <?php
    }

    private function renderFilenameQueueApplyAllBox(): void
    {
        $queueUrl = admin_url('tools.php?page=wpsn-queue');

        $searchKey = $this->q('s');
        $search = isset($_GET[$searchKey]) ? sanitize_text_field((string) wp_unslash($_GET[$searchKey])) : '';

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

        $postTypes = get_post_types(['public' => true], 'objects');
        if (!is_array($postTypes)) {
            $postTypes = [];
        }

        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Apply all rename drafts (Queue-first)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('Queues apply jobs for filename rename drafts (matching current filters).', 'wp-seo-next'); ?></p>
                <p class="description">
                    <?php echo esc_html__('After queueing, open Queue Center to run worker if needed.', 'wp-seo-next'); ?>
                    <a href="<?php echo esc_url($queueUrl); ?>"><?php echo esc_html__('Open Queue Center', 'wp-seo-next'); ?></a>
                </p>

                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>">
                    <?php wp_nonce_field('wpsn_media_filename_queue_apply_all', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_media_filename_queue_apply_all" />
                    <input type="hidden" name="search" value="<?php echo esc_attr($search); ?>" />
                    <input type="hidden" name="source_filter" value="<?php echo esc_attr($sourceFilter); ?>" />

                    <p>
                        <label><input type="checkbox" name="replace_in_content" value="1" /> <?php echo esc_html__('Also replace old image URLs in post_content (optional)', 'wp-seo-next'); ?></label>
                    </p>

                    <div style="margin: 8px 0 14px 0; padding-left: 18px;">
                        <p style="margin: 0 0 6px 0;"><strong><?php echo esc_html__('Post types to scan (when enabled):', 'wp-seo-next'); ?></strong></p>
                        <?php
                        foreach ($postTypes as $pt => $obj) {
                            if (!is_object($obj)) {
                                continue;
                            }
                            if (in_array((string) $pt, ['attachment', 'revision', 'nav_menu_item'], true)) {
                                continue;
                            }
                            $checked = in_array((string) $pt, ['post', 'page'], true);
                            ?>
                            <label style="display:inline-block; margin-right:10px;">
                                <input type="checkbox" name="replace_post_types[]" value="<?php echo esc_attr((string) $pt); ?>" <?php checked($checked); ?> />
                                <?php echo esc_html((string) $obj->labels->singular_name); ?>
                            </label>
                            <?php
                        }
                        ?>
                        <p class="description" style="margin-top:6px;">
                            <?php echo esc_html__('Tip: keep this off if you use a CDN/offload plugin that rewrites URLs, or if you prefer to update content manually.', 'wp-seo-next'); ?>
                        </p>
                    </div>

                    <p>
                        <label for="wpsn-media-fn-queue-apply-limit"><?php echo esc_html__('Limit (current filters)', 'wp-seo-next'); ?></label>
                        <input type="number" id="wpsn-media-fn-queue-apply-limit" name="limit" value="200" min="1" max="20000" style="width:120px;" />
                    </p>

                    <p>
                        <button class="button button-primary"><?php echo esc_html__('Queue apply jobs', 'wp-seo-next'); ?></button>
                    </p>
                </form>
            </div>
        </div>
        <?php
    }

    private function renderFilenameQueueClearDraftsBox(): void
    {
        $queueUrl = admin_url('tools.php?page=wpsn-queue');

        $searchKey = $this->q('s');
        $search = isset($_GET[$searchKey]) ? sanitize_text_field((string) wp_unslash($_GET[$searchKey])) : '';

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

        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Clear filename drafts (Direct)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('Deletes filename draft suggestions (matching current filters). This does not touch the real files.', 'wp-seo-next'); ?></p>
                <p class="description">
                    <?php echo esc_html__('Queue Center is not required for this action.', 'wp-seo-next'); ?>
                    <a href="<?php echo esc_url($queueUrl); ?>"><?php echo esc_html__('Open Queue Center', 'wp-seo-next'); ?></a>
                </p>

                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" onsubmit="return confirm('<?php echo esc_js(__('Clear all matching filename drafts?', 'wp-seo-next')); ?>');">
                    <?php wp_nonce_field('wpsn_media_filename_queue_clear_drafts', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_media_filename_queue_clear_drafts" />
                    <input type="hidden" name="search" value="<?php echo esc_attr($search); ?>" />
                    <input type="hidden" name="source_filter" value="<?php echo esc_attr($sourceFilter); ?>" />

                    <p>
                        <label for="wpsn-media-fn-queue-clear-limit"><?php echo esc_html__('Limit (current filters)', 'wp-seo-next'); ?></label>
                        <input type="number" id="wpsn-media-fn-queue-clear-limit" name="limit" value="2000" min="1" max="20000" style="width:120px;" />
                    </p>

                    <p>
                        <button class="button"><?php echo esc_html__('Clear drafts now', 'wp-seo-next'); ?></button>
                    </p>
                </form>
            </div>
        </div>
        <?php
    }


    private function renderGenerateBox(): void
    {
        $localEnabled = (bool) $this->settings->get('ai.local.enabled', true);
        $openaiEnabled = (bool) $this->settings->get('ai.external.enabled', false);

        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Generate ALT drafts (AJAX)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('This will create draft suggestions for image attachments. You can then review and apply them.', 'wp-seo-next'); ?></p>

                <table class="form-table" role="presentation">
                    <tr>
                        <th scope="row"><label for="wpsn-media-generate-source"><?php echo esc_html__('Source', 'wp-seo-next'); ?></label></th>
                        <td>
                            <select id="wpsn-media-generate-source">
                                <option value="local" <?php selected(true, true); ?> <?php disabled(!$localEnabled); ?>><?php echo esc_html__('Local (rules)', 'wp-seo-next'); ?></option>
                                <option value="openai" <?php disabled(!$openaiEnabled); ?>><?php echo esc_html__('OpenAI', 'wp-seo-next'); ?></option>
                            </select>
                            <?php if (!$localEnabled) : ?>
                                <p class="description" style="color:#b32d2e;"><?php echo esc_html__('Local suggestions are disabled in Settings → AI.', 'wp-seo-next'); ?></p>
                            <?php endif; ?>
                            <?php if (!$openaiEnabled) : ?>
                                <p class="description"><?php echo esc_html__('Enable OpenAI in Settings → AI and add an API key to use this option.', 'wp-seo-next'); ?></p>
                            <?php endif; ?>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><?php echo esc_html__('Filters', 'wp-seo-next'); ?></th>
                        <td>
                            <label><input type="checkbox" id="wpsn-media-missing-alt-only" checked /> <?php echo esc_html__('Only images with empty ALT', 'wp-seo-next'); ?></label><br />
                            <label><input type="checkbox" id="wpsn-media-only-if-empty" checked /> <?php echo esc_html__('Only generate if current ALT is empty (safety)', 'wp-seo-next'); ?></label><br />
                            <label><input type="checkbox" id="wpsn-media-overwrite-suggestions" /> <?php echo esc_html__('Overwrite existing drafts', 'wp-seo-next'); ?></label>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><?php echo esc_html__('OpenAI options', 'wp-seo-next'); ?></th>
                        <td>
                            <label><input type="checkbox" id="wpsn-media-openai-vision" /> <?php echo esc_html__('Include image URL (vision) for better accuracy', 'wp-seo-next'); ?></label>
                            <p class="description"><?php echo esc_html__('If enabled, the attachment URL will be sent to OpenAI. Make sure the URL is accessible and contains no sensitive content.', 'wp-seo-next'); ?></p>

                            <label for="wpsn-media-vision-detail"><?php echo esc_html__('Vision detail', 'wp-seo-next'); ?></label>
                            <select id="wpsn-media-vision-detail">
                                <option value="low" selected><?php echo esc_html__('Low (faster/cheaper)', 'wp-seo-next'); ?></option>
                                <option value="auto"><?php echo esc_html__('Auto', 'wp-seo-next'); ?></option>
                                <option value="high"><?php echo esc_html__('High (slower)', 'wp-seo-next'); ?></option>
                            </select>

                            <p>
                                <label for="wpsn-media-backoff-seconds"><?php echo esc_html__('On HTTP 429, wait (seconds)', 'wp-seo-next'); ?></label>
                                <input type="number" id="wpsn-media-backoff-seconds" value="10" min="1" max="120" style="width:90px;" />
                            </p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="wpsn-media-generate-limit"><?php echo esc_html__('Limit', 'wp-seo-next'); ?></label></th>
                        <td>
                            <input type="number" id="wpsn-media-generate-limit" value="500" min="1" max="20000" style="width:120px;" />
                            <p class="description"><?php echo esc_html__('How many attachments to process in this run.', 'wp-seo-next'); ?></p>
                        </td>
                    </tr>
                </table>

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

                <div class="wpsn-media-progress" id="wpsn-media-ajax-generate-progress" style="display:none;">
                    <div class="wpsn-progress"><div class="wpsn-progress__bar" id="wpsn-media-ajax-generate-bar"></div></div>
                    <p id="wpsn-media-ajax-generate-status" style="margin-top:8px;"></p>
                </div>
            </div>
        </div>
        <?php
    }

    private function renderFilenameGenerateBox(): void
    {
        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Generate filename rename drafts (AJAX)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('This will create draft suggestions for renaming image files (filenames only). Review drafts before applying.', 'wp-seo-next'); ?></p>

                <table class="form-table" role="presentation">
                    <tr>
                        <th scope="row"><?php echo esc_html__('Filters', 'wp-seo-next'); ?></th>
                        <td>
                            <label><input type="checkbox" id="wpsn-media-fn-only-if-dirty" checked /> <?php echo esc_html__('Only images with "dirty" filenames (spaces/UPPERCASE/cyrillic + configured patterns)', 'wp-seo-next'); ?></label>
                            <p class="description" style="margin:6px 0 0 0;"><?php echo esc_html__('Configure patterns in Settings → Media SEO.', 'wp-seo-next'); ?></p>
                            <br />
                            <label><input type="checkbox" id="wpsn-media-fn-overwrite-suggestions" /> <?php echo esc_html__('Overwrite existing drafts', 'wp-seo-next'); ?></label>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="wpsn-media-fn-max-base-len"><?php echo esc_html__('Max base length', 'wp-seo-next'); ?></label></th>
                        <td>
                            <input type="number" id="wpsn-media-fn-max-base-len" value="80" min="20" max="120" style="width:120px;" />
                            <p class="description"><?php echo esc_html__('The filename base (without extension) will be trimmed to this length.', 'wp-seo-next'); ?></p>
                        </td>
                    </tr>
                    <tr>
                        <th scope="row"><label for="wpsn-media-fn-generate-limit"><?php echo esc_html__('Limit', 'wp-seo-next'); ?></label></th>
                        <td>
                            <input type="number" id="wpsn-media-fn-generate-limit" value="500" min="1" max="20000" style="width:120px;" />
                            <p class="description"><?php echo esc_html__('How many attachments to process in this run.', 'wp-seo-next'); ?></p>
                        </td>
                    </tr>
                </table>

                <p>
                    <button class="button button-primary" id="wpsn-media-fn-ajax-generate-now"><?php echo esc_html__('Generate rename drafts now', 'wp-seo-next'); ?></button>
                    <button class="button" id="wpsn-media-fn-ajax-generate-cancel"><?php echo esc_html__('Cancel', 'wp-seo-next'); ?></button>
                    <span class="spinner" id="wpsn-media-fn-ajax-generate-spinner" style="float:none;"></span>
                </p>

                <div class="wpsn-media-progress" id="wpsn-media-fn-ajax-generate-progress" style="display:none;">
                    <div class="wpsn-progress"><div class="wpsn-progress__bar" id="wpsn-media-fn-ajax-generate-bar"></div></div>
                    <p id="wpsn-media-fn-ajax-generate-status" style="margin-top:8px;"></p>
                </div>
            </div>
        </div>
        <?php
    }

    private function renderApplyAllBox(): void
    {
        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Apply all drafts (AJAX)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('Apply draft ALT suggestions to attachments (with undo history).', 'wp-seo-next'); ?></p>

                <p>
                    <label><input type="checkbox" id="wpsn-media-apply-only-changed" checked /> <?php echo esc_html__('Apply only changed (avoid unnecessary writes)', 'wp-seo-next'); ?></label>
                </p>

                <p>
                    <label for="wpsn-media-apply-limit"><?php echo esc_html__('Limit (current filters)', 'wp-seo-next'); ?></label>
                    <input type="number" id="wpsn-media-apply-limit" value="500" min="1" max="20000" style="width:120px;" />
                </p>

                <p>
                    <button class="button button-primary" id="wpsn-media-ajax-apply-all"><?php echo esc_html__('Apply all (with progress)', 'wp-seo-next'); ?></button>
                    <button class="button" id="wpsn-media-ajax-cancel"><?php echo esc_html__('Cancel', 'wp-seo-next'); ?></button>
                    <span class="spinner" id="wpsn-media-ajax-spinner" style="float:none;"></span>
                </p>

                <div class="wpsn-media-progress" id="wpsn-media-ajax-progress" style="display:none;">
                    <div class="wpsn-progress"><div class="wpsn-progress__bar" id="wpsn-media-ajax-bar"></div></div>
                    <p id="wpsn-media-ajax-status" style="margin-top:8px;"></p>
                </div>
            </div>
        </div>
        <?php
    }

    private function renderFilenameApplyAllBox(): void
    {
        $postTypes = get_post_types(['public' => true], 'objects');
        if (!is_array($postTypes)) {
            $postTypes = [];
        }

        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Apply all rename drafts (AJAX)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('Apply filename rename drafts: renames the original file and available generated sizes, updates attachment metadata, and optionally replaces old URLs inside post content.', 'wp-seo-next'); ?></p>

                <p>
                    <label><input type="checkbox" id="wpsn-media-fn-replace-in-content" /> <?php echo esc_html__('Also replace old image URLs in post_content (optional)', 'wp-seo-next'); ?></label>
                </p>

                <div style="margin: 8px 0 14px 0; padding-left: 18px;">
                    <p style="margin: 0 0 6px 0;"><strong><?php echo esc_html__('Post types to scan (when enabled):', 'wp-seo-next'); ?></strong></p>
                    <?php
                    foreach ($postTypes as $pt => $obj) {
                        if (!is_object($obj)) {
                            continue;
                        }
                        if (in_array((string) $pt, ['attachment', 'revision', 'nav_menu_item'], true)) {
                            continue;
                        }
                        $checked = in_array((string) $pt, ['post', 'page'], true);
                        ?>
                        <label style="display:inline-block; margin-right:10px;">
                            <input type="checkbox" class="wpsn-media-fn-replace-pt" value="<?php echo esc_attr((string) $pt); ?>" <?php checked($checked); ?> />
                            <?php echo esc_html((string) $obj->labels->singular_name); ?>
                        </label>
                        <?php
                    }
                    ?>
                    <p class="description" style="margin-top:6px;">
                        <?php echo esc_html__('Tip: keep this off if you use a CDN/offload plugin that rewrites URLs, or if you prefer to update content manually.', 'wp-seo-next'); ?>
                    </p>
                </div>

                <p>
                    <label for="wpsn-media-fn-apply-limit"><?php echo esc_html__('Limit (current filters)', 'wp-seo-next'); ?></label>
                    <input type="number" id="wpsn-media-fn-apply-limit" value="200" min="1" max="20000" style="width:120px;" />
                </p>

                <p>
                    <button class="button button-primary" id="wpsn-media-fn-ajax-apply-all"><?php echo esc_html__('Apply all renames (with progress)', 'wp-seo-next'); ?></button>
                    <button class="button" id="wpsn-media-fn-ajax-cancel"><?php echo esc_html__('Cancel', 'wp-seo-next'); ?></button>
                    <span class="spinner" id="wpsn-media-fn-ajax-spinner" style="float:none;"></span>
                </p>

                <div class="wpsn-media-progress" id="wpsn-media-fn-ajax-progress" style="display:none;">
                    <div class="wpsn-progress"><div class="wpsn-progress__bar" id="wpsn-media-fn-ajax-bar"></div></div>
                    <p id="wpsn-media-fn-ajax-status" style="margin-top:8px;"></p>
                </div>
            </div>
        </div>
        <?php
    }

    private function renderFilenameClearDraftsBox(): void
    {
        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Clear filename drafts (AJAX)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('This will delete draft rename suggestions for matching attachments (it will NOT touch already applied renames or undo history).', 'wp-seo-next'); ?></p>

                <p>
                    <label for="wpsn-media-fn-clear-limit"><strong><?php echo esc_html__('Limit (current filters)', 'wp-seo-next'); ?></strong></label>
                    <input type="number" id="wpsn-media-fn-clear-limit" value="500" min="1" max="20000" style="width:120px;" />
                </p>

                <p>
                    <button class="button" id="wpsn-media-fn-ajax-clear-drafts"><?php echo esc_html__('Clear draft rename suggestions', 'wp-seo-next'); ?></button>
                    <button class="button" id="wpsn-media-fn-ajax-clear-cancel"><?php echo esc_html__('Cancel', 'wp-seo-next'); ?></button>
                    <span class="spinner" id="wpsn-media-fn-ajax-clear-spinner" style="float:none;"></span>
                </p>

                <div class="wpsn-media-progress" id="wpsn-media-fn-ajax-clear-progress" style="display:none;">
                    <div class="wpsn-progress"><div class="wpsn-progress__bar" id="wpsn-media-fn-ajax-clear-bar"></div></div>
                    <p id="wpsn-media-fn-ajax-clear-status" style="margin-top:8px;"></p>
                </div>
            </div>
        </div>
        <?php
    }

    private function renderFilenameCsvBox(string $view, string $search, string $sourceFilter): void
    {
        $previewToken = isset($_GET['fn_import_preview']) ? sanitize_key((string) wp_unslash($_GET['fn_import_preview'])) : '';
        $preview = null;
        if ($previewToken !== '') {
            $maybe = get_transient($this->filenameImportPreviewKey($previewToken));
            if (is_array($maybe)) {
                $preview = $maybe;
            }
        }

        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Filename CSV (export / import drafts)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('Export rename drafts to CSV, edit the suggested filename column, then import back. Tip: use __CLEAR__ to explicitly clear a draft.', 'wp-seo-next'); ?></p>

                <h3 style="margin-top: 0;"><?php echo esc_html__('Export', 'wp-seo-next'); ?></h3>
                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="wpsn-media__csv-export">
                    <?php wp_nonce_field('wpsn_media_filename_export_csv', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_media_filename_export_csv" />
                    <input type="hidden" name="view" value="<?php echo esc_attr($view); ?>" />
                    <input type="hidden" name="s" value="<?php echo esc_attr($search); ?>" />
                    <input type="hidden" name="source" value="<?php echo esc_attr($sourceFilter); ?>" />

                    <label for="wpsn-media-fn-export-limit"><strong><?php echo esc_html__('Limit', 'wp-seo-next'); ?></strong></label>
                    <input type="number" id="wpsn-media-fn-export-limit" name="limit" value="5000" min="1" max="20000" style="width:120px;" />
                    <button type="submit" class="button"><?php echo esc_html__('Export CSV', 'wp-seo-next'); ?></button>
                </form>

                <hr style="margin: 16px 0;" />

                <h3><?php echo esc_html__('Import (drafts only)', 'wp-seo-next'); ?></h3>

                <?php if ($view !== 'draft') : ?>
                    <p class="description"><?php echo esc_html__('Switch to the "Drafts" tab to import and update draft rename suggestions.', 'wp-seo-next'); ?></p>
                <?php endif; ?>

                <?php if (is_array($preview)) : ?>
                    <?php
                        $sum = isset($preview['summary']) && is_array($preview['summary']) ? $preview['summary'] : [];
                        $rows = isset($preview['rows']) && is_array($preview['rows']) ? $preview['rows'] : [];
                        $errs = isset($preview['errors']) && is_array($preview['errors']) ? $preview['errors'] : [];

                        $toUpdate = isset($sum['update']) ? (int) $sum['update'] : 0;
                        $toCreate = isset($sum['create']) ? (int) $sum['create'] : 0;
                        $toClear = isset($sum['clear']) ? (int) $sum['clear'] : 0;
                        $skipped = isset($sum['skipped']) ? (int) $sum['skipped'] : 0;
                        $totalRows = isset($sum['total_rows']) ? (int) $sum['total_rows'] : 0;
                    ?>

                    <div class="notice notice-info" style="margin: 12px 0;">
                        <p>
                            <?php echo esc_html(sprintf(__('Preview: %d CSV rows. Updates: %d. Creates: %d. Clears: %d. Skipped: %d.', 'wp-seo-next'), $totalRows, $toUpdate, $toCreate, $toClear, $skipped)); ?>
                        </p>
                        <?php if (!empty($errs)) : ?>
                            <p><strong><?php echo esc_html__('Warnings:', 'wp-seo-next'); ?></strong></p>
                            <ul style="margin: 6px 0 0 18px;">
                                <?php foreach (array_slice($errs, 0, 8) as $e) : ?>
                                    <li><?php echo esc_html((string) $e); ?></li>
                                <?php endforeach; ?>
                            </ul>
                        <?php endif; ?>
                    </div>

                    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-bottom: 12px;">
                        <?php wp_nonce_field('wpsn_media_filename_import_apply', 'wpsn_nonce'); ?>
                        <input type="hidden" name="action" value="wpsn_media_filename_import_apply" />
                        <input type="hidden" name="token" value="<?php echo esc_attr($previewToken); ?>" />
                        <button type="submit" class="button button-primary"><?php echo esc_html__('Apply import', 'wp-seo-next'); ?></button>
                        <a class="button" href="<?php echo esc_url($this->pageUrl([$this->q('mode') => 'filenames', $this->q('view') => 'draft'])); ?>"><?php echo esc_html__('Discard preview', 'wp-seo-next'); ?></a>
                    </form>

                    <table class="widefat fixed striped" style="max-width:1200px;">
                        <thead>
                            <tr>
                                <th style="width:90px;"><?php echo esc_html__('ID', 'wp-seo-next'); ?></th>
                                <th><?php echo esc_html__('Attachment', 'wp-seo-next'); ?></th>
                                <th style="width:22%;"><?php echo esc_html__('Current suggested', 'wp-seo-next'); ?></th>
                                <th style="width:22%;"><?php echo esc_html__('Imported suggested', 'wp-seo-next'); ?></th>
                                <th style="width:90px;"><?php echo esc_html__('Action', 'wp-seo-next'); ?></th>
                            </tr>
                        </thead>
                        <tbody>
                            <?php if (empty($rows)) : ?>
                                <tr><td colspan="5"><?php echo esc_html__('No changes to apply.', 'wp-seo-next'); ?></td></tr>
                            <?php else : ?>
                                <?php foreach (array_slice($rows, 0, 200) as $r) : ?>
                                    <?php
                                        $rid = isset($r['id']) ? (int) $r['id'] : 0;
                                        $title = isset($r['title']) ? (string) $r['title'] : '';
                                        $old = isset($r['old']) ? (string) $r['old'] : '';
                                        $new = isset($r['new']) ? (string) $r['new'] : '';
                                        $act = isset($r['action']) ? (string) $r['action'] : '';
                                    ?>
                                    <tr>
                                        <td><?php echo (int) $rid; ?></td>
                                        <td><?php echo esc_html($title); ?></td>
                                        <td><?php echo esc_html($old); ?></td>
                                        <td><?php echo esc_html($new); ?></td>
                                        <td><?php echo esc_html($act); ?></td>
                                    </tr>
                                <?php endforeach; ?>
                            <?php endif; ?>
                        </tbody>
                    </table>

                    <?php if (count($rows) > 200) : ?>
                        <p class="description"><?php echo esc_html(sprintf(__('Showing first %d changes…', 'wp-seo-next'), 200)); ?></p>
                    <?php endif; ?>
                <?php else : ?>
                    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" enctype="multipart/form-data" class="wpsn-media__csv-import">
                        <?php wp_nonce_field('wpsn_media_filename_import_preview', 'wpsn_nonce'); ?>
                        <input type="hidden" name="action" value="wpsn_media_filename_import_preview" />
                        <input type="hidden" name="view" value="<?php echo esc_attr($view); ?>" />

                        <input type="file" name="csv" accept="text/csv,.csv" required <?php echo $view !== 'draft' ? 'disabled' : ''; ?> />

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

                        <label style="margin-left:12px;">
                            <?php echo esc_html__('Empty cells:', 'wp-seo-next'); ?>
                            <select name="empty_cells" <?php echo $view !== 'draft' ? 'disabled' : ''; ?>>
                                <option value="ignore" selected><?php echo esc_html__('Ignore (keep existing)', 'wp-seo-next'); ?></option>
                                <option value="clear"><?php echo esc_html__('Clear draft', 'wp-seo-next'); ?></option>
                            </select>
                        </label>

                        <label style="margin-left:12px;">
                            <?php echo esc_html__('Clear token:', 'wp-seo-next'); ?>
                            <input type="text" name="clear_token" value="__CLEAR__" style="width:120px;" <?php echo $view !== 'draft' ? 'disabled' : ''; ?> />
                        </label>

                        <label style="margin-left:12px;">
                            <input type="checkbox" name="create_if_missing" value="1" <?php echo $view !== 'draft' ? 'disabled' : ''; ?> />
                            <?php echo esc_html__('Create draft if missing', 'wp-seo-next'); ?>
                        </label>

                        <button type="submit" class="button" style="margin-left:12px;" <?php echo $view !== 'draft' ? 'disabled' : ''; ?>><?php echo esc_html__('Upload & Preview', 'wp-seo-next'); ?></button>
                    </form>
                <?php endif; ?>
            </div>
        </div>
        <?php
    }

    private function renderFilenameRollbackCsvBox(string $search, string $sourceFilter): void
    {
        $previewToken = isset($_GET['fn_rollback_import_preview']) ? sanitize_key((string) wp_unslash($_GET['fn_rollback_import_preview'])) : '';
        $preview = null;
        if ($previewToken !== '') {
            $maybe = get_transient($this->filenameRollbackImportPreviewKey($previewToken));
            if (is_array($maybe)) {
                $preview = $maybe;
            }
        }

        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Filename Rollback CSV (export / import undo list)', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('Export the rollback list (applied renames with undo history), then import back to undo selected renames in bulk.', 'wp-seo-next'); ?></p>

                <h3 style="margin-top:0;"><?php echo esc_html__('Export undo list', 'wp-seo-next'); ?></h3>
                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" class="wpsn-media__csv-export">
                    <?php wp_nonce_field('wpsn_media_filename_rollback_export_csv', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_media_filename_rollback_export_csv" />
                    <input type="hidden" name="s" value="<?php echo esc_attr($search); ?>" />
                    <input type="hidden" name="source" value="<?php echo esc_attr($sourceFilter); ?>" />

                    <label for="wpsn-media-fn-rb-export-limit"><strong><?php echo esc_html__('Limit', 'wp-seo-next'); ?></strong></label>
                    <input type="number" id="wpsn-media-fn-rb-export-limit" name="limit" value="5000" min="1" max="20000" style="width:120px;" />
                    <button type="submit" class="button"><?php echo esc_html__('Export rollback CSV', 'wp-seo-next'); ?></button>
                </form>

                <hr style="margin: 16px 0;" />

                <h3><?php echo esc_html__('Import & undo', 'wp-seo-next'); ?></h3>
                <p class="description"><?php echo esc_html__('Tip: edit the "do_undo" column (1/0) to choose which rows should be undone. Undo uses the stored rollback snapshots (if missing, the row will be skipped).', 'wp-seo-next'); ?></p>

                <?php if (is_array($preview)) : ?>
                    <?php
                        $sum = isset($preview['summary']) && is_array($preview['summary']) ? $preview['summary'] : [];
                        $rows = isset($preview['rows']) && is_array($preview['rows']) ? $preview['rows'] : [];
                        $errs = isset($preview['errors']) && is_array($preview['errors']) ? $preview['errors'] : [];

                        $toUndo = isset($sum['undo']) ? (int) $sum['undo'] : 0;
                        $skipped = isset($sum['skipped']) ? (int) $sum['skipped'] : 0;
                        $totalRows = isset($sum['total_rows']) ? (int) $sum['total_rows'] : 0;
                    ?>

                    <div class="notice notice-info" style="margin: 12px 0;">
                        <p>
                            <?php echo esc_html(sprintf(__('Preview: %d CSV rows. To undo: %d. Skipped: %d.', 'wp-seo-next'), $totalRows, $toUndo, $skipped)); ?>
                        </p>
                        <?php if (!empty($errs)) : ?>
                            <p><strong><?php echo esc_html__('Warnings:', 'wp-seo-next'); ?></strong></p>
                            <ul style="margin: 6px 0 0 18px;">
                                <?php foreach (array_slice($errs, 0, 8) as $e) : ?>
                                    <li><?php echo esc_html((string) $e); ?></li>
                                <?php endforeach; ?>
                            </ul>
                        <?php endif; ?>
                    </div>

                    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin-bottom: 12px;">
                        <?php wp_nonce_field('wpsn_media_filename_rollback_import_apply', 'wpsn_nonce'); ?>
                        <input type="hidden" name="action" value="wpsn_media_filename_rollback_import_apply" />
                        <input type="hidden" name="token" value="<?php echo esc_attr($previewToken); ?>" />
                        <button type="submit" class="button button-primary"><?php echo esc_html__('Undo selected', 'wp-seo-next'); ?></button>
                        <a class="button" href="<?php echo esc_url($this->pageUrl([$this->q('mode') => 'filenames', $this->q('view') => 'applied'])); ?>"><?php echo esc_html__('Discard preview', 'wp-seo-next'); ?></a>
                    </form>

                    <table class="widefat fixed striped" style="max-width:1200px;">
                        <thead>
                            <tr>
                                <th style="width:90px;"><?php echo esc_html__('ID', 'wp-seo-next'); ?></th>
                                <th><?php echo esc_html__('Attachment', 'wp-seo-next'); ?></th>
                                <th style="width:22%;"><?php echo esc_html__('Current filename', 'wp-seo-next'); ?></th>
                                <th style="width:22%;"><?php echo esc_html__('Undo target', 'wp-seo-next'); ?></th>
                                <th style="width:90px;"><?php echo esc_html__('Action', 'wp-seo-next'); ?></th>
                            </tr>
                        </thead>
                        <tbody>
                            <?php if (empty($rows)) : ?>
                                <tr><td colspan="5"><?php echo esc_html__('No changes to apply.', 'wp-seo-next'); ?></td></tr>
                            <?php else : ?>
                                <?php foreach (array_slice($rows, 0, 200) as $r) : ?>
                                    <?php
                                        $rid = isset($r['id']) ? (int) $r['id'] : 0;
                                        $title = isset($r['title']) ? (string) $r['title'] : '';
                                        $cur = isset($r['current']) ? (string) $r['current'] : '';
                                        $target = isset($r['target']) ? (string) $r['target'] : '';
                                        $act = isset($r['action']) ? (string) $r['action'] : '';
                                    ?>
                                    <tr>
                                        <td><?php echo (int) $rid; ?></td>
                                        <td><?php echo esc_html($title); ?></td>
                                        <td><code><?php echo esc_html($cur); ?></code></td>
                                        <td><code><?php echo esc_html($target); ?></code></td>
                                        <td><?php echo esc_html($act); ?></td>
                                    </tr>
                                <?php endforeach; ?>
                            <?php endif; ?>
                        </tbody>
                    </table>

                    <?php if (count($rows) > 200) : ?>
                        <p class="description"><?php echo esc_html(sprintf(__('Showing first %d rows…', 'wp-seo-next'), 200)); ?></p>
                    <?php endif; ?>

                <?php else : ?>
                    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" enctype="multipart/form-data" class="wpsn-media__csv-import">
                        <?php wp_nonce_field('wpsn_media_filename_rollback_import_preview', 'wpsn_nonce'); ?>
                        <input type="hidden" name="action" value="wpsn_media_filename_rollback_import_preview" />
                        <input type="file" name="csv" accept="text/csv,.csv" required />

                        <label style="margin-left:12px;">
                            <?php echo esc_html__('Action column:', 'wp-seo-next'); ?>
                            <input type="text" name="action_column" value="do_undo" style="width:120px;" />
                        </label>

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

    private function renderFilenameCsvHistory(): void
    {
        $repo = new FilenameCsvHistoryRepository();
        $items = $repo->all();

        $typeKey = $this->q('history_type');
        $filterType = isset($_GET[$typeKey]) ? sanitize_key((string) wp_unslash($_GET[$typeKey])) : '';
        if (!in_array($filterType, ['', 'draft_import', 'rollback_import'], true)) {
            $filterType = '';
        }

        if ($filterType !== '') {
            $items = array_values(array_filter($items, static function ($e) use ($filterType) {
                return is_array($e) && isset($e['type']) && (string) $e['type'] === $filterType;
            }));
        }

        // Newest first.
        usort($items, static function ($a, $b) {
            $ta = is_array($a) && isset($a['created_at']) ? (int) $a['created_at'] : 0;
            $tb = is_array($b) && isset($b['created_at']) ? (int) $b['created_at'] : 0;
            return $tb <=> $ta;
        });

        ?>
        <div class="postbox" style="max-width:1200px;">
            <div class="postbox-header">
                <h2 class="hndle"><?php echo esc_html__('Filename CSV history', 'wp-seo-next'); ?></h2>
            </div>
            <div class="inside">
                <p><?php echo esc_html__('A lightweight journal of recent CSV imports for filename drafts and rollback.', 'wp-seo-next'); ?></p>

                <form method="get" action="<?php echo esc_url(admin_url('tools.php')); ?>" style="margin: 10px 0; max-width: 1200px;">
                    <input type="hidden" name="page" value="<?php echo esc_attr($this->basePageSlug); ?>" />
                    <?php foreach ($this->basePageArgs as $k => $v) : ?>
                        <input type="hidden" name="<?php echo esc_attr($k); ?>" value="<?php echo esc_attr($v); ?>" />
                    <?php endforeach; ?>
                    <input type="hidden" name="<?php echo esc_attr($this->q('mode')); ?>" value="filenames" />
                    <input type="hidden" name="<?php echo esc_attr($this->q('view')); ?>" value="csv_history" />

                    <label for="wpsn-fn-csv-h-type"><strong><?php echo esc_html__('Filter', 'wp-seo-next'); ?></strong></label>
                    <select id="wpsn-fn-csv-h-type" name="<?php echo esc_attr($typeKey); ?>">
                        <option value="" <?php selected($filterType, ''); ?>><?php echo esc_html__('All imports', 'wp-seo-next'); ?></option>
                        <option value="draft_import" <?php selected($filterType, 'draft_import'); ?>><?php echo esc_html__('Draft imports', 'wp-seo-next'); ?></option>
                        <option value="rollback_import" <?php selected($filterType, 'rollback_import'); ?>><?php echo esc_html__('Rollback imports', 'wp-seo-next'); ?></option>
                    </select>
                    <input type="submit" class="button" value="<?php echo esc_attr__('Apply', 'wp-seo-next'); ?>" />
                    <a class="button" href="<?php echo esc_url($this->pageUrlClean([$this->q('mode') => 'filenames', $this->q('view') => 'csv_history'])); ?>"><?php echo esc_html__('Reset', 'wp-seo-next'); ?></a>
                </form>

                <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="margin: 10px 0;">
                    <?php wp_nonce_field('wpsn_media_filename_csv_history_clear', 'wpsn_nonce'); ?>
                    <input type="hidden" name="action" value="wpsn_media_filename_csv_history_clear" />
                    <button type="submit" class="button" onclick="return confirm('<?php echo esc_js(__('Clear CSV history?', 'wp-seo-next')); ?>');">
                        <?php echo esc_html__('Clear history', 'wp-seo-next'); ?>
                    </button>
                </form>

                <table class="widefat fixed striped" style="max-width: 1200px;">
                    <thead>
                        <tr>
                            <th style="width:160px;"><?php echo esc_html__('Date', 'wp-seo-next'); ?></th>
                            <th style="width:140px;"><?php echo esc_html__('Type', 'wp-seo-next'); ?></th>
                            <th style="width:180px;"><?php echo esc_html__('User', 'wp-seo-next'); ?></th>
                            <th><?php echo esc_html__('File', 'wp-seo-next'); ?></th>
                            <th style="width:260px;"><?php echo esc_html__('Summary', 'wp-seo-next'); ?></th>
                            <th style="width:220px;"><?php echo esc_html__('Warnings', 'wp-seo-next'); ?></th>
                        </tr>
                    </thead>
                    <tbody>
                        <?php if (empty($items)) : ?>
                            <tr><td colspan="6"><?php echo esc_html__('No imports yet.', 'wp-seo-next'); ?></td></tr>
                        <?php else : ?>
                            <?php foreach ($items as $e) : ?>
                                <?php
                                    if (!is_array($e)) {
                                        continue;
                                    }
                                    $ts = isset($e['created_at']) ? (int) $e['created_at'] : 0;
                                    $date = $ts ? wp_date('Y-m-d H:i', $ts) : '';
                                    $type = isset($e['type']) ? (string) $e['type'] : '';
                                    $typeLabel = $type;
                                    if ($type === 'draft_import') {
                                        $typeLabel = __('Draft import', 'wp-seo-next');
                                    } elseif ($type === 'rollback_import') {
                                        $typeLabel = __('Rollback import', 'wp-seo-next');
                                    }

                                    $userId = isset($e['user_id']) ? (int) $e['user_id'] : 0;
                                    $userLabel = $userId ? (string) $userId : '';
                                    if ($userId) {
                                        $u = get_userdata($userId);
                                        if ($u && isset($u->display_name)) {
                                            $userLabel = (string) $u->display_name;
                                        }
                                    }
                                    $file = isset($e['file_name']) ? (string) $e['file_name'] : '';
                                    $summary = isset($e['summary']) && is_array($e['summary']) ? $e['summary'] : [];
                                    $errors = isset($e['errors']) && is_array($e['errors']) ? $e['errors'] : [];

                                    $sumParts = [];
                                    foreach ($summary as $k => $v) {
                                        if (is_scalar($v)) {
                                            $sumParts[] = $k . ': ' . (string) $v;
                                        }
                                    }
                                    $sumText = !empty($sumParts) ? implode(', ', $sumParts) : '';
                                ?>
                                <tr>
                                    <td><?php echo esc_html($date); ?></td>
                                    <td><?php echo esc_html($typeLabel); ?></td>
                                    <td><?php echo esc_html($userLabel); ?></td>
                                    <td><?php echo esc_html($file); ?></td>
                                    <td><?php echo esc_html($sumText); ?></td>
                                    <td>
                                        <?php if (empty($errors)) : ?>
                                            <span class="description"><?php echo esc_html__('None', 'wp-seo-next'); ?></span>
                                        <?php else : ?>
                                            <details>
                                                <summary><?php echo esc_html(sprintf(_n('%d warning', '%d warnings', count($errors), 'wp-seo-next'), count($errors))); ?></summary>
                                                <ul style="margin: 6px 0 0 18px;">
                                                    <?php foreach ($errors as $er) : ?>
                                                        <li><?php echo esc_html((string) $er); ?></li>
                                                    <?php endforeach; ?>
                                                </ul>
                                            </details>
                                        <?php endif; ?>
                                    </td>
                                </tr>
                            <?php endforeach; ?>
                        <?php endif; ?>
                    </tbody>
                </table>
            </div>
        </div>
        <?php
    }

    private function renderFilters(string $view, string $mode, string $search, string $sourceFilter, int $perPage): void
    {
        $resetUrl = $this->pageUrlClean([
            $this->q('mode') => $mode,
            $this->q('view') => $view,
        ]);

        ?>
        <form method="get" action="<?php echo esc_url(admin_url('tools.php')); ?>" style="margin: 10px 0; max-width: 1200px;">
            <input type="hidden" name="page" value="<?php echo esc_attr($this->basePageSlug); ?>" />
            <?php foreach ($this->basePageArgs as $k => $v) : ?>
                <input type="hidden" name="<?php echo esc_attr($k); ?>" value="<?php echo esc_attr($v); ?>" />
            <?php endforeach; ?>
            <input type="hidden" name="<?php echo esc_attr($this->q('mode')); ?>" value="<?php echo esc_attr($mode); ?>" />
            <input type="hidden" name="<?php echo esc_attr($this->q('view')); ?>" value="<?php echo esc_attr($view); ?>" />

            <p class="search-box" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
                <input type="search" id="wpsn-media-suggestions-search" name="<?php echo esc_attr($this->q('s')); ?>" value="<?php echo esc_attr($search); ?>" placeholder="<?php echo esc_attr__('Search attachments…', 'wp-seo-next'); ?>" />

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

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

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

    private function renderFilenameFilters(string $view, string $mode, string $search, string $sourceFilter, int $perPage): void
    {
        $resetUrl = $this->pageUrlClean([
            $this->q('mode') => $mode,
            $this->q('view') => $view,
        ]);

        ?>
        <form method="get" action="<?php echo esc_url(admin_url('tools.php')); ?>" style="margin: 10px 0; max-width: 1200px;">
            <input type="hidden" name="page" value="<?php echo esc_attr($this->basePageSlug); ?>" />
            <?php foreach ($this->basePageArgs as $k => $v) : ?>
                <input type="hidden" name="<?php echo esc_attr($k); ?>" value="<?php echo esc_attr($v); ?>" />
            <?php endforeach; ?>
            <input type="hidden" name="<?php echo esc_attr($this->q('mode')); ?>" value="<?php echo esc_attr($mode); ?>" />
            <input type="hidden" name="<?php echo esc_attr($this->q('view')); ?>" value="<?php echo esc_attr($view); ?>" />

            <p class="search-box" style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
                <input type="search" id="wpsn-media-fn-suggestions-search" name="<?php echo esc_attr($this->q('s')); ?>" value="<?php echo esc_attr($search); ?>" placeholder="<?php echo esc_attr__('Search attachments…', 'wp-seo-next'); ?>" />

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

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

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

    private function renderSuggestionsTable(\WP_Query $q, string $view): void
    {
        $isDraft = ($view === 'draft');

        ?>
        <table class="widefat fixed striped" style="max-width: 1200px;">
            <thead>
                <tr>
                    <th style="width:70px;"><?php echo esc_html__('Image', 'wp-seo-next'); ?></th>
                    <th><?php echo esc_html__('Attachment', 'wp-seo-next'); ?></th>
                    <th style="width:22%;"><?php echo esc_html__('Current ALT', 'wp-seo-next'); ?></th>
                    <th style="width:22%;"><?php echo esc_html__('Suggested ALT', 'wp-seo-next'); ?></th>
                    <th style="width:90px;"><?php echo esc_html__('Source', 'wp-seo-next'); ?></th>
                    <th style="width:120px;"><?php echo esc_html__('Updated', 'wp-seo-next'); ?></th>
                    <th style="width:130px;"><?php echo esc_html__('Actions', 'wp-seo-next'); ?></th>
                </tr>
            </thead>
            <tbody>
                <?php if (empty($q->posts)) : ?>
                    <tr><td colspan="7"><?php echo esc_html__('No suggestions found.', 'wp-seo-next'); ?></td></tr>
                <?php else : ?>
                    <?php foreach ($q->posts as $post) : ?>
                        <?php
                            $id = (int) $post->ID;
                            $thumb = wp_get_attachment_image($id, [60, 60], true, ['style' => 'max-width:60px;height:auto;']);
                            $editLink = get_edit_post_link($id, '');

                            $currentAlt = (string) get_post_meta($id, '_wp_attachment_image_alt', true);

                            $raw = (string) get_post_meta($id, MediaJobs::META_SUGGESTIONS, true);
                            $data = $raw !== '' ? json_decode($raw, true) : null;
                            if (!is_array($data)) {
                                $data = [];
                            }
                            $fields = isset($data['fields']) && is_array($data['fields']) ? $data['fields'] : [];
                            $suggestedAlt = isset($fields['alt_text']['text']) ? (string) $fields['alt_text']['text'] : '';

                            $source = (string) get_post_meta($id, MediaJobs::META_SOURCE, true);
                            $updatedTs = (int) get_post_meta($id, MediaJobs::META_UPDATED, true);
                            $updated = $updatedTs ? gmdate('Y-m-d H:i', $updatedTs) : '';
                        ?>
                        <tr>
                            <td><?php echo $thumb ? $thumb : ''; ?></td>
                            <td>
                                <strong><a href="<?php echo esc_url($editLink); ?>"><?php echo esc_html(get_the_title($id)); ?></a></strong>
                                <div class="description"><?php echo esc_html(wp_basename((string) get_attached_file($id))); ?></div>
                            </td>
                            <td><?php echo esc_html($currentAlt); ?></td>
                            <td><?php echo esc_html($suggestedAlt); ?></td>
                            <td><?php echo esc_html($source); ?></td>
                            <td><?php echo esc_html($updated); ?></td>
                            <td>
                                <?php if ($isDraft) : ?>
                                    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
                                        <?php wp_nonce_field('wpsn_media_action', 'wpsn_nonce'); ?>
                                        <input type="hidden" name="action" value="wpsn_media_apply_single" />
                                        <input type="hidden" name="id" value="<?php echo (int) $id; ?>" />
                                        <input type="submit" class="button button-small" value="<?php echo esc_attr__('Apply', 'wp-seo-next'); ?>" />
                                    </form>
                                <?php else : ?>
                                    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
                                        <?php wp_nonce_field('wpsn_media_action', 'wpsn_nonce'); ?>
                                        <input type="hidden" name="action" value="wpsn_media_undo_single" />
                                        <input type="hidden" name="id" value="<?php echo (int) $id; ?>" />
                                        <input type="submit" class="button button-small" value="<?php echo esc_attr__('Undo', 'wp-seo-next'); ?>" />
                                    </form>
                                <?php endif; ?>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                <?php endif; ?>
            </tbody>
        </table>
        <?php
    }

    private function renderFilenameSuggestionsTable(\WP_Query $q, string $view): void
    {
        $isDraft = ($view === 'draft');

        ?>
        <table class="widefat fixed striped" style="max-width: 1200px;">
            <thead>
                <tr>
                    <th style="width:70px;"><?php echo esc_html__('Image', 'wp-seo-next'); ?></th>
                    <th><?php echo esc_html__('Attachment', 'wp-seo-next'); ?></th>
                    <th style="width:22%;"><?php echo esc_html__('Current filename', 'wp-seo-next'); ?></th>
                    <th style="width:22%;"><?php echo esc_html__('Suggested filename', 'wp-seo-next'); ?></th>
                    <th style="width:90px;"><?php echo esc_html__('Source', 'wp-seo-next'); ?></th>
                    <th style="width:120px;"><?php echo esc_html__('Updated', 'wp-seo-next'); ?></th>
                    <th style="width:130px;"><?php echo esc_html__('Actions', 'wp-seo-next'); ?></th>
                </tr>
            </thead>
            <tbody>
                <?php if (empty($q->posts)) : ?>
                    <tr><td colspan="7"><?php echo esc_html__('No suggestions found.', 'wp-seo-next'); ?></td></tr>
                <?php else : ?>
                    <?php foreach ($q->posts as $post) : ?>
                        <?php
                            $id = (int) $post->ID;
                            $thumb = wp_get_attachment_image($id, [60, 60], true, ['style' => 'max-width:60px;height:auto;']);
                            $editLink = get_edit_post_link($id, '');

                            $abs = (string) get_attached_file($id);
                            $currentBasename = $abs !== '' ? wp_basename($abs) : '';

                            $raw = (string) get_post_meta($id, MediaFilenameJobs::META_SUGGESTIONS, true);
                            $data = $raw !== '' ? json_decode($raw, true) : null;
                            if (!is_array($data)) {
                                $data = [];
                            }
                            $suggested = '';
                            if (isset($data['suggested']) && is_array($data['suggested']) && isset($data['suggested']['basename'])) {
                                $suggested = (string) $data['suggested']['basename'];
                            }

                            $source = (string) get_post_meta($id, MediaFilenameJobs::META_SOURCE, true);
                            $updatedTs = (int) get_post_meta($id, MediaFilenameJobs::META_UPDATED, true);
                            $updated = $updatedTs ? gmdate('Y-m-d H:i', $updatedTs) : '';
                        ?>
                        <tr>
                            <td><?php echo $thumb ? $thumb : ''; ?></td>
                            <td>
                                <strong><a href="<?php echo esc_url($editLink); ?>"><?php echo esc_html(get_the_title($id)); ?></a></strong>
                                <div class="description"><?php echo esc_html($currentBasename); ?></div>
                            </td>
                            <td><code><?php echo esc_html($currentBasename); ?></code></td>
                            <td><code><?php echo esc_html($suggested); ?></code></td>
                            <td><?php echo esc_html($source); ?></td>
                            <td><?php echo esc_html($updated); ?></td>
                            <td>
                                <?php if ($isDraft) : ?>
                                    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
                                        <input type="hidden" name="action" value="wpsn_media_fn_apply_single" />
                                        <?php wp_nonce_field('wpsn_media_action', 'wpsn_nonce'); ?>
                                        <input type="hidden" name="id" value="<?php echo (int) $id; ?>" />
                                        <button class="button button-primary button-small" type="submit"><?php echo esc_html__('Apply', 'wp-seo-next'); ?></button>
                                    </form>
                                <?php else : ?>
                                    <form method="post" action="<?php echo esc_url(admin_url('admin-post.php')); ?>" style="display:inline;">
                                        <input type="hidden" name="action" value="wpsn_media_fn_undo_single" />
                                        <?php wp_nonce_field('wpsn_media_action', 'wpsn_nonce'); ?>
                                        <input type="hidden" name="id" value="<?php echo (int) $id; ?>" />
                                        <button class="button button-small" type="submit"><?php echo esc_html__('Undo', 'wp-seo-next'); ?></button>
                                    </form>
                                <?php endif; ?>
                            </td>
                        </tr>
                    <?php endforeach; ?>
                <?php endif; ?>
            </tbody>
        </table>
        <?php
    }

    private function renderPagination(int $maxPages, int $paged, string $view, string $mode, string $search, string $sourceFilter, int $perPage): void
    {
        if ($maxPages <= 1) {
            return;
        }

        $base = $this->pageUrl([
            $this->q('mode') => $mode,
            $this->q('view') => $view,
            $this->q('s') => $search,
            $this->q('source') => $sourceFilter,
            $this->q('per_page') => (string) $perPage,
            $this->q('paged') => '%#%',
        ]);

        echo '<div class="tablenav" style="max-width:1200px; margin-top:10px;">';
        echo paginate_links([
            'base' => $base,
            'format' => '',
            'current' => $paged,
            'total' => $maxPages,
            'prev_text' => __('« Prev', 'wp-seo-next'),
            'next_text' => __('Next »', 'wp-seo-next'),
        ]);
        echo '</div>';
    }

    /**
     * @return \WP_Query
     */
    private function buildSuggestionsQuery(string $status, string $search, string $sourceFilter, int $perPage, int $paged, bool $idsOnly, string $metaStatusKey, string $metaSourceKey)
    {
        $args = [
            'post_type' => 'attachment',
            'post_status' => 'inherit',
            'posts_per_page' => $perPage,
            'paged' => $paged,
            'orderby' => 'ID',
            'order' => 'DESC',
            's' => $search,
            'meta_query' => [
                [
                    'key' => $metaStatusKey,
                    'value' => $status,
                    'compare' => '=',
                ],
            ],
        ];

        if ($metaSourceKey !== '' && $sourceFilter !== '') {
            $args['meta_query'][] = [
                'key' => $metaSourceKey,
                'value' => $sourceFilter,
                'compare' => '=',
            ];
        }

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

        return new \WP_Query($args);
    }

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

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

        return (int) $count;
    }

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

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

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

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

        $maxLen = isset($_POST['max_length']) ? (int) $_POST['max_length'] : 125;
        $maxLen = max(20, min(300, $maxLen));

        $useVision = !empty($_POST['use_vision']);
        $visionDetail = isset($_POST['vision_detail']) ? sanitize_key((string) wp_unslash($_POST['vision_detail'])) : 'low';
        if (!in_array($visionDetail, ['low', 'high', 'auto'], true)) {
            $visionDetail = 'low';
        }

        // Basic safety: do not allow OpenAI if disabled.
        if ($source === 'openai' && !(bool) $this->settings->get('ai.external.enabled', false)) {
            $this->redirectWithNotice(__('OpenAI is disabled in Settings → AI.', 'wp-seo-next'), 'error');
        }
        if ($source === 'local' && !(bool) $this->settings->get('ai.local.enabled', true)) {
            $this->redirectWithNotice(__('Local suggestions are disabled in Settings → AI.', 'wp-seo-next'), 'error');
        }

        $metaQuery = ['relation' => 'AND'];

        if ($missingAltOnly) {
            $metaQuery[] = [
                'relation' => 'OR',
                ['key' => '_wp_attachment_image_alt', 'compare' => 'NOT EXISTS'],
                ['key' => '_wp_attachment_image_alt', 'value' => '', 'compare' => '='],
            ];
        }

        // Skip attachments that already have suggestions unless overwrite requested.
        if (!$overwrite) {
            $metaQuery[] = [
                'relation' => 'OR',
                ['key' => MediaJobs::META_STATUS, 'compare' => 'NOT EXISTS'],
                ['key' => MediaJobs::META_STATUS, 'value' => '', 'compare' => '='],
            ];
        }

        $qArgs = [
            'post_type' => 'attachment',
            'post_status' => 'inherit',
            'post_mime_type' => 'image',
            'fields' => 'ids',
            'posts_per_page' => $limit,
            'orderby' => 'ID',
            'order' => 'DESC',
            'no_found_rows' => true,
        ];

        if (count($metaQuery) > 1) {
            $qArgs['meta_query'] = $metaQuery;
        }

        $q = new \WP_Query($qArgs);
        $ids = is_array($q->posts) ? array_values(array_map('intval', $q->posts)) : [];

        if (empty($ids)) {
            $this->redirectWithNotice(__('No matching attachments found for this run.', 'wp-seo-next'), 'info');
        }

        $queued = 0;
        $userId = (int) get_current_user_id();

        foreach ($ids as $id) {
            $this->queue->enqueue('media_alt_generate', [
                'attachment_id' => (int) $id,
                'user_id' => $userId,
                'source' => $source,
                'only_if_empty' => ($onlyIfEmpty || $missingAltOnly) ? 1 : 0,
                'overwrite_suggestions' => $overwrite ? 1 : 0,
                'max_length' => $maxLen,
                'use_vision' => $useVision ? 1 : 0,
                'vision_detail' => $visionDetail,
            ], time() + 1);
            $queued++;
        }

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

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

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

        $applyOnlyChanged = !empty($_POST['apply_only_changed']);

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

        $q = $this->buildSuggestionsQuery('draft', $search, $sourceFilter, $limit, 1, true, MediaJobs::META_STATUS, MediaJobs::META_SOURCE);
        $ids = is_array($q->posts) ? array_values(array_map('intval', $q->posts)) : [];

        if (empty($ids)) {
            $this->redirectWithNotice(__('No draft suggestions to apply (with current filters).', 'wp-seo-next'), 'info');
        }

        $queued = 0;

        foreach ($ids as $id) {
            $this->queue->enqueue('media_alt_apply', [
                'attachment_id' => (int) $id,
                'apply_only_changed' => $applyOnlyChanged ? 1 : 0,
            ], time() + 1);
            $queued++;
        }

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

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

        $onlyIfDirty = array_key_exists('only_if_dirty', $_POST) ? (bool) ((int) $_POST['only_if_dirty']) : true;
        $overwrite = !empty($_POST['overwrite_suggestions']);

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

        $maxBaseLen = isset($_POST['max_base_len']) ? (int) $_POST['max_base_len'] : 80;
        $maxBaseLen = max(20, min(120, $maxBaseLen));

        $metaQuery = ['relation' => 'AND'];

        if (!$overwrite) {
            $metaQuery[] = [
                'relation' => 'OR',
                ['key' => MediaFilenameJobs::META_STATUS, 'compare' => 'NOT EXISTS'],
                ['key' => MediaFilenameJobs::META_STATUS, 'value' => '', 'compare' => '='],
            ];
        }

        $qArgs = [
            'post_type' => 'attachment',
            'post_status' => 'inherit',
            'post_mime_type' => 'image',
            'fields' => 'ids',
            'posts_per_page' => $limit,
            'orderby' => 'ID',
            'order' => 'DESC',
            'no_found_rows' => true,
        ];

        if (count($metaQuery) > 1) {
            $qArgs['meta_query'] = $metaQuery;
        }

        $q = new \WP_Query($qArgs);
        $ids = is_array($q->posts) ? array_values(array_map('intval', $q->posts)) : [];

        if (empty($ids)) {
            $this->redirectWithNotice(__('No matching attachments found for this run.', 'wp-seo-next'), 'info');
        }

        $queued = 0;

        foreach ($ids as $id) {
            $this->queue->enqueue('media_filename_generate', [
                'attachment_id' => (int) $id,
                'overwrite_suggestions' => $overwrite ? 1 : 0,
                'only_if_dirty' => $onlyIfDirty ? 1 : 0,
                'max_base_len' => $maxBaseLen,
            ], time() + 1);
            $queued++;
        }

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

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

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

        $replaceInContent = !empty($_POST['replace_in_content']);
        $replacePostTypes = [];
        if (isset($_POST['replace_post_types']) && is_array($_POST['replace_post_types'])) {
            foreach (wp_unslash($_POST['replace_post_types']) as $pt) {
                $pt = sanitize_key((string) $pt);
                if ($pt !== '' && $pt !== 'attachment') {
                    $replacePostTypes[] = $pt;
                }
            }
        }

        $search = isset($_POST['search']) ? sanitize_text_field((string) wp_unslash($_POST['search'])) : '';
        $sourceFilter = isset($_POST['source_filter']) ? sanitize_key((string) wp_unslash($_POST['source_filter'])) : '';
        if (!in_array($sourceFilter, ['', 'local'], true)) {
            $sourceFilter = '';
        }

        $q = $this->buildSuggestionsQuery('draft', $search, $sourceFilter, $limit, 1, true, MediaFilenameJobs::META_STATUS, MediaFilenameJobs::META_SOURCE);
        $ids = is_array($q->posts) ? array_values(array_map('intval', $q->posts)) : [];

        if (empty($ids)) {
            $this->redirectWithNotice(__('No draft rename suggestions to apply (with current filters).', 'wp-seo-next'), 'info');
        }

        $queued = 0;

        foreach ($ids as $id) {
            $this->queue->enqueue('media_filename_apply', [
                'attachment_id' => (int) $id,
                'replace_in_content' => $replaceInContent ? 1 : 0,
                'replace_post_types' => $replacePostTypes,
            ], time() + 1);
            $queued++;
        }

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

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

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

        $search = isset($_POST['search']) ? sanitize_text_field((string) wp_unslash($_POST['search'])) : '';
        $sourceFilter = isset($_POST['source_filter']) ? sanitize_key((string) wp_unslash($_POST['source_filter'])) : '';
        if (!in_array($sourceFilter, ['', 'local'], true)) {
            $sourceFilter = '';
        }

        $q = $this->buildSuggestionsQuery('draft', $search, $sourceFilter, $limit, 1, true, MediaFilenameJobs::META_STATUS, MediaFilenameJobs::META_SOURCE);
        $ids = is_array($q->posts) ? array_values(array_map('intval', $q->posts)) : [];

        if (empty($ids)) {
            $this->redirectWithNotice(__('No filename drafts found (with current filters).', 'wp-seo-next'), 'info');
        }

        $cleared = 0;
        foreach ($ids as $id) {
            delete_post_meta((int) $id, MediaFilenameJobs::META_SUGGESTIONS);
            delete_post_meta((int) $id, MediaFilenameJobs::META_STATUS);
            delete_post_meta((int) $id, MediaFilenameJobs::META_SOURCE);
            delete_post_meta((int) $id, MediaFilenameJobs::META_UPDATED);
            $cleared++;
        }

        $this->redirectWithNotice(sprintf(__('Cleared %d filename draft(s).', 'wp-seo-next'), $cleared), 'success');
    }


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

        $id = isset($_POST['id']) ? (int) $_POST['id'] : 0;
        if ($id <= 0) {
            $this->redirectWithNotice(__('Invalid attachment.', 'wp-seo-next'), 'error');
        }

        $jobs = new MediaJobs($this->settings, $this->logger);
        $jobs->handleApplyAlt(null, ['attachment_id' => $id, 'apply_only_changed' => 1], []);

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

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

        $id = isset($_POST['id']) ? (int) $_POST['id'] : 0;
        if ($id <= 0) {
            $this->redirectWithNotice(__('Invalid attachment.', 'wp-seo-next'), 'error');
        }

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

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

        $history = [];
        if (isset($data['history']) && is_array($data['history'])) {
            $history = $data['history'];
        } elseif (isset($data['alt'])) {
            $history = [[
                'saved_at' => isset($data['saved_at']) ? (string) $data['saved_at'] : '',
                'alt' => (string) ($data['alt'] ?? ''),
            ]];
        }

        if (empty($history)) {
            $this->redirectWithNotice(__('No undo snapshot found.', 'wp-seo-next'), 'warning');
        }

        $entry = array_pop($history);
        $prevAlt = is_array($entry) && isset($entry['alt']) ? (string) $entry['alt'] : '';

        if ($prevAlt === '') {
            delete_post_meta($id, '_wp_attachment_image_alt');
        } else {
            update_post_meta($id, '_wp_attachment_image_alt', $prevAlt);
        }

        if (empty($history)) {
            delete_post_meta($id, MediaJobs::META_UNDO);
        } else {
            update_post_meta($id, MediaJobs::META_UNDO, wp_json_encode([
                'version' => 2,
                'history' => array_values($history),
            ]));
        }

        // Move suggestion back to draft.
        $sRaw = (string) get_post_meta($id, MediaJobs::META_SUGGESTIONS, true);
        if ($sRaw !== '') {
            $s = json_decode($sRaw, true);
            if (is_array($s)) {
                $s['status'] = 'draft';
                unset($s['applied_at']);
                update_post_meta($id, MediaJobs::META_SUGGESTIONS, wp_json_encode($s));
            }
        }
        update_post_meta($id, MediaJobs::META_STATUS, 'draft');
        update_post_meta($id, MediaJobs::META_UPDATED, time());

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

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

        $id = isset($_POST['id']) ? (int) $_POST['id'] : 0;
        if ($id <= 0) {
            $this->redirectWithNotice(__('Invalid attachment.', 'wp-seo-next'), 'error');
        }

        $jobs = new MediaFilenameJobs($this->settings, $this->logger);
        $res = $jobs->handleApplyFilename(null, ['attachment_id' => $id, 'replace_in_content' => 0], []);

        if (is_array($res) && ($res['status'] ?? '') === 'failed') {
            $msg = isset($res['error']) && $res['error'] !== '' ? (string) $res['error'] : __('Failed to apply rename.', 'wp-seo-next');
            $this->redirectWithNotice($msg, 'error');
        }

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

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

        $id = isset($_POST['id']) ? (int) $_POST['id'] : 0;
        if ($id <= 0) {
            $this->redirectWithNotice(__('Invalid attachment.', 'wp-seo-next'), 'error');
        }

        $jobs = new MediaFilenameJobs($this->settings, $this->logger);
        $res = $jobs->undoLast($id);

        if (!is_array($res) || empty($res['ok'])) {
            $msg = is_array($res) && isset($res['error']) && $res['error'] !== '' ? (string) $res['error'] : __('No undo snapshot found.', 'wp-seo-next');
            $this->redirectWithNotice($msg, 'warning');
        }

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

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

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

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

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

        $q = $this->buildSuggestionsQuery($view, $search, $sourceFilter, $limit, 1, false, MediaFilenameJobs::META_STATUS, MediaFilenameJobs::META_SOURCE);

        $filename = 'wpsn-filename-' . $view . '-' . gmdate('Ymd-His') . '.csv';

        // Prevent any stray output from breaking the CSV.
        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        nocache_headers();
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');

        $out = fopen('php://output', 'w');
        if ($out === false) {
            wp_die(esc_html__('Unable to export CSV.', 'wp-seo-next'));
        }

        fputcsv($out, [
            'attachment_id',
            'attachment_title',
            'current_rel_path',
            'current_basename',
            'suggested_basename',
            'suggested_rel_path',
            'status',
            'source',
            'updated_at',
        ]);

        if (!empty($q->posts)) {
            foreach ($q->posts as $post) {
                $id = (int) $post->ID;
                if ($id <= 0) {
                    continue;
                }

                $title = get_the_title($id);

                $raw = (string) get_post_meta($id, MediaFilenameJobs::META_SUGGESTIONS, true);
                $data = $raw !== '' ? json_decode($raw, true) : null;
                if (!is_array($data)) {
                    $data = [];
                }

                $currentRel = isset($data['current']['rel_path']) ? (string) $data['current']['rel_path'] : (string) get_post_meta($id, '_wp_attached_file', true);
                $currentBase = isset($data['current']['basename']) ? (string) $data['current']['basename'] : wp_basename($currentRel);

                $suggestedRel = isset($data['suggested']['rel_path']) ? (string) $data['suggested']['rel_path'] : '';
                $suggestedBase = isset($data['suggested']['basename']) ? (string) $data['suggested']['basename'] : wp_basename($suggestedRel);

                $status = (string) get_post_meta($id, MediaFilenameJobs::META_STATUS, true);
                $source = (string) get_post_meta($id, MediaFilenameJobs::META_SOURCE, true);
                $updatedTs = (int) get_post_meta($id, MediaFilenameJobs::META_UPDATED, true);
                $updated = $updatedTs ? gmdate('c', $updatedTs) : '';

                fputcsv($out, [
                    $id,
                    $title,
                    $currentRel,
                    $currentBase,
                    $suggestedBase,
                    $suggestedRel,
                    $status,
                    $source,
                    $updated,
                ]);
            }
        }

        fclose($out);
        exit;
    }

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

        $view = isset($_POST['view']) ? sanitize_key((string) wp_unslash($_POST['view'])) : 'draft';
        if ($view !== 'draft') {
            $this->redirectWithNotice(__('Import is only available in Drafts view.', 'wp-seo-next'), 'error');
        }

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

        $tmp = (string) $_FILES['csv']['tmp_name'];
        $origName = isset($_FILES['csv']['name']) ? sanitize_text_field((string) $_FILES['csv']['name']) : '';
        if (!is_uploaded_file($tmp)) {
            $this->redirectWithNotice(__('Upload failed.', 'wp-seo-next'), 'error');
        }

        $applyOnlyChanged = isset($_POST['apply_only_changed']) && (string) wp_unslash($_POST['apply_only_changed']) === '1';
        $emptyCells = isset($_POST['empty_cells']) ? sanitize_key((string) wp_unslash($_POST['empty_cells'])) : 'ignore';
        if (!in_array($emptyCells, ['ignore', 'clear'], true)) {
            $emptyCells = 'ignore';
        }

        $clearToken = isset($_POST['clear_token']) ? trim(sanitize_text_field((string) wp_unslash($_POST['clear_token']))) : '__CLEAR__';
        if ($clearToken === '') {
            $clearToken = '__CLEAR__';
        }

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

        $parseErrors = [];
        $parsed = $this->readCsvFile($tmp, $parseErrors);
        if (!is_array($parsed) || empty($parsed['header']) || empty($parsed['rows'])) {
            $msg = !empty($parseErrors) ? (string) reset($parseErrors) : __('CSV is empty or invalid.', 'wp-seo-next');
            $this->redirectWithNotice($msg, 'error');
        }

        $header = $parsed['header'];
        $rows = $parsed['rows'];
        $map = $this->mapFilenameCsvHeader($header);

        if (!isset($map['id']) || (!isset($map['suggested_basename']) && !isset($map['suggested_rel_path']))) {
            $this->redirectWithNotice(__('CSV must include attachment_id (or id) and suggested_basename (or suggested_rel_path).', 'wp-seo-next'), 'error');
        }

        $ops = [];
        $previewRows = [];
        $errors = $parseErrors;

        $summary = [
            'total_rows' => 0,
            'update' => 0,
            'create' => 0,
            'clear' => 0,
            'skipped' => 0,
        ];

        $uploads = wp_upload_dir();
        $baseurl = isset($uploads['baseurl']) ? (string) $uploads['baseurl'] : '';

        $maxRows = 20000;
        foreach ($rows as $i => $row) {
            if ($summary['total_rows'] >= $maxRows) {
                $errors[] = sprintf(__('Stopped at %d rows (safety limit).', 'wp-seo-next'), $maxRows);
                break;
            }
            $summary['total_rows']++;

            $idCell = $row[$map['id']] ?? '';
            $id = (int) trim((string) $idCell);
            if ($id <= 0) {
                $summary['skipped']++;
                $errors[] = sprintf(__('Row %d: missing attachment_id.', 'wp-seo-next'), $i + 2);
                continue;
            }

            $attachment = get_post($id);
            if (!$attachment || $attachment->post_type !== 'attachment') {
                $summary['skipped']++;
                $errors[] = sprintf(__('Row %d: attachment %d not found.', 'wp-seo-next'), $i + 2, $id);
                continue;
            }

            $mime = (string) get_post_mime_type($id);
            if ($mime === '' || strpos($mime, 'image/') !== 0) {
                $summary['skipped']++;
                $errors[] = sprintf(__('Row %d: attachment %d is not an image.', 'wp-seo-next'), $i + 2, $id);
                continue;
            }

            $rawValue = '';
            if (isset($map['suggested_rel_path'])) {
                $rawValue = (string) ($row[$map['suggested_rel_path']] ?? '');
            }
            if ($rawValue === '' && isset($map['suggested_basename'])) {
                $rawValue = (string) ($row[$map['suggested_basename']] ?? '');
            }

            $rawValue = trim($rawValue);
            $existingStatus = (string) get_post_meta($id, MediaFilenameJobs::META_STATUS, true);
            $existingRaw = (string) get_post_meta($id, MediaFilenameJobs::META_SUGGESTIONS, true);
            $existing = $existingRaw !== '' ? json_decode($existingRaw, true) : null;

            $oldSuggestedRel = is_array($existing) && isset($existing['suggested']['rel_path']) ? (string) $existing['suggested']['rel_path'] : '';
            $oldSuggestedBase = is_array($existing) && isset($existing['suggested']['basename']) ? (string) $existing['suggested']['basename'] : wp_basename($oldSuggestedRel);

            $title = get_the_title($id);

            // Clear draft.
            if (($rawValue === '' && $emptyCells === 'clear') || ($clearToken !== '' && $rawValue === $clearToken)) {
                if ($existingStatus !== 'draft') {
                    $summary['skipped']++;
                    $errors[] = sprintf(__('Row %d: attachment %d has no draft suggestion to clear.', 'wp-seo-next'), $i + 2, $id);
                    continue;
                }

                $ops[] = ['action' => 'clear', 'id' => $id];
                $previewRows[] = [
                    'id' => $id,
                    'title' => $title,
                    'old' => $oldSuggestedBase,
                    'new' => '',
                    'action' => 'clear',
                ];
                $summary['clear']++;
                continue;
            }

            if ($rawValue === '') {
                $summary['skipped']++;
                continue;
            }

            // Normalize to a safe basename within the current uploads directory.
            $norm = $this->normalizeImportedFilename($id, $rawValue);
            if ($norm === '') {
                $summary['skipped']++;
                $errors[] = sprintf(__('Row %d: attachment %d invalid suggested filename.', 'wp-seo-next'), $i + 2, $id);
                continue;
            }

            $newRel = $norm;
            $newBase = wp_basename($newRel);

            if ($existingStatus === 'draft' && is_array($existing)) {
                if ($applyOnlyChanged && $oldSuggestedRel !== '' && $oldSuggestedRel === $newRel) {
                    $summary['skipped']++;
                    continue;
                }

                $ops[] = ['action' => 'update', 'id' => $id, 'new_rel' => $newRel];
                $previewRows[] = [
                    'id' => $id,
                    'title' => $title,
                    'old' => $oldSuggestedBase,
                    'new' => $newBase,
                    'action' => 'update',
                ];
                $summary['update']++;
                continue;
            }

            if ($createIfMissing && ($existingStatus === '' || $existingRaw === '')) {
                $ops[] = ['action' => 'create', 'id' => $id, 'new_rel' => $newRel];
                $previewRows[] = [
                    'id' => $id,
                    'title' => $title,
                    'old' => '',
                    'new' => $newBase,
                    'action' => 'create',
                ];
                $summary['create']++;
                continue;
            }

            $summary['skipped']++;
            if ($existingStatus !== 'draft') {
                $errors[] = sprintf(__('Row %d: attachment %d is not a draft suggestion (status: %s).', 'wp-seo-next'), $i + 2, $id, $existingStatus !== '' ? $existingStatus : 'none');
            } else {
                $errors[] = sprintf(__('Row %d: attachment %d has no suggestion data to update.', 'wp-seo-next'), $i + 2, $id);
            }
        }

        $token = preg_replace('/[^a-zA-Z0-9]/', '', wp_generate_uuid4());
        if ($token === '') {
            $token = (string) time();
        }

        set_transient($this->filenameImportPreviewKey($token), [
            'file_name' => $origName,
            'ops' => $ops,
            'summary' => $summary,
            'rows' => $previewRows,
            'errors' => array_values($errors),
            'created_at' => time(),
        ], 30 * MINUTE_IN_SECONDS);

        $ref = wp_get_referer();
        if (!$ref) {
            $ref = $this->pageUrl([$this->q('mode') => 'filenames', $this->q('view') => 'draft']);
        }
        $url = remove_query_arg(['wpsn_notice', 'wpsn_notice_type', 'fn_import_preview'], $ref);
        $url = add_query_arg([
            'fn_import_preview' => $token,
            'wpsn_notice' => __('Import preview is ready.', 'wp-seo-next'),
            'wpsn_notice_type' => 'info',
        ], $url);
        wp_safe_redirect($url);
        exit;
    }

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

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

        $key = $this->filenameImportPreviewKey($token);
        $preview = get_transient($key);
        if (!is_array($preview) || !isset($preview['ops']) || !is_array($preview['ops'])) {
            $this->redirectWithNotice(__('Import preview expired or invalid.', 'wp-seo-next'), 'error');
        }

        $fileName = isset($preview['file_name']) ? (string) $preview['file_name'] : '';
        $errs = isset($preview['errors']) && is_array($preview['errors']) ? $preview['errors'] : [];

        $ops = $preview['ops'];
        $uploads = wp_upload_dir();
        $baseurl = isset($uploads['baseurl']) ? (string) $uploads['baseurl'] : '';

        $counts = ['update' => 0, 'create' => 0, 'clear' => 0, 'skipped' => 0];

        foreach ($ops as $op) {
            if (!is_array($op) || empty($op['action']) || empty($op['id'])) {
                continue;
            }
            $action = (string) $op['action'];
            $id = (int) $op['id'];
            if ($id <= 0) {
                continue;
            }

            if ($action === 'clear') {
                // Clear only drafts (safety).
                $status = (string) get_post_meta($id, MediaFilenameJobs::META_STATUS, true);
                if ($status !== 'draft') {
                    $counts['skipped']++;
                    continue;
                }

                delete_post_meta($id, MediaFilenameJobs::META_SUGGESTIONS);
                delete_post_meta($id, MediaFilenameJobs::META_STATUS);
                delete_post_meta($id, MediaFilenameJobs::META_SOURCE);
                delete_post_meta($id, MediaFilenameJobs::META_UPDATED);
                $counts['clear']++;
                continue;
            }

            $newRel = isset($op['new_rel']) ? (string) $op['new_rel'] : '';
            if ($newRel === '') {
                $counts['skipped']++;
                continue;
            }

            $newBase = wp_basename($newRel);
            $newUrl = $baseurl !== '' ? rtrim($baseurl, '/') . '/' . ltrim($newRel, '/') : '';

            if ($action === 'update') {
                $status = (string) get_post_meta($id, MediaFilenameJobs::META_STATUS, true);
                if ($status !== 'draft') {
                    $counts['skipped']++;
                    continue;
                }

                $raw = (string) get_post_meta($id, MediaFilenameJobs::META_SUGGESTIONS, true);
                $data = $raw !== '' ? json_decode($raw, true) : null;
                if (!is_array($data)) {
                    $counts['skipped']++;
                    continue;
                }

                if (!isset($data['suggested']) || !is_array($data['suggested'])) {
                    $data['suggested'] = [];
                }
                $data['suggested']['rel_path'] = $newRel;
                $data['suggested']['basename'] = $newBase;
                $data['suggested']['url'] = $newUrl;
                $data['status'] = 'draft';

                update_post_meta($id, MediaFilenameJobs::META_SUGGESTIONS, wp_json_encode($data));
                update_post_meta($id, MediaFilenameJobs::META_STATUS, 'draft');
                update_post_meta($id, MediaFilenameJobs::META_SOURCE, 'local');
                update_post_meta($id, MediaFilenameJobs::META_UPDATED, time());

                $counts['update']++;
                continue;
            }

            if ($action === 'create') {
                // Safety: don't overwrite existing suggestions.
                $existing = (string) get_post_meta($id, MediaFilenameJobs::META_SUGGESTIONS, true);
                if ($existing !== '') {
                    $counts['skipped']++;
                    continue;
                }

                $currentRel = (string) get_post_meta($id, '_wp_attached_file', true);
                $currentBase = wp_basename($currentRel);
                $currentUrl = $baseurl !== '' && $currentRel !== '' ? rtrim($baseurl, '/') . '/' . ltrim($currentRel, '/') : '';

                $suggestion = [
                    'version' => 1,
                    'status' => 'draft',
                    'source' => 'local',
                    'generated_at' => gmdate('c'),
                    'current' => [
                        'rel_path' => $currentRel,
                        'basename' => $currentBase,
                        'url' => $currentUrl,
                    ],
                    'suggested' => [
                        'rel_path' => $newRel,
                        'basename' => $newBase,
                        'url' => $newUrl,
                    ],
                    'meta' => [
                        'imported' => true,
                    ],
                ];

                update_post_meta($id, MediaFilenameJobs::META_SUGGESTIONS, wp_json_encode($suggestion));
                update_post_meta($id, MediaFilenameJobs::META_STATUS, 'draft');
                update_post_meta($id, MediaFilenameJobs::META_SOURCE, 'local');
                update_post_meta($id, MediaFilenameJobs::META_UPDATED, time());

                $counts['create']++;
            }
        }

        delete_transient($key);

        // History entry.
        $repo = new FilenameCsvHistoryRepository();
        $repo->append([
            'type' => 'draft_import',
            'created_at' => time(),
            'user_id' => (int) get_current_user_id(),
            'file_name' => $fileName,
            'summary' => $counts,
            'errors' => $errs,
        ]);

        $msg = sprintf(
            __('Import applied. Updates: %d. Creates: %d. Clears: %d. Skipped: %d.', 'wp-seo-next'),
            (int) $counts['update'],
            (int) $counts['create'],
            (int) $counts['clear'],
            (int) $counts['skipped']
        );

        $ref = wp_get_referer();
        if (!$ref) {
            $ref = $this->pageUrl([$this->q('mode') => 'filenames', $this->q('view') => 'draft']);
        }
        $url = remove_query_arg(['wpsn_notice', 'wpsn_notice_type', 'fn_import_preview'], $ref);
        $url = add_query_arg([
            'wpsn_notice' => $msg,
            'wpsn_notice_type' => 'success',
        ], $url);
        wp_safe_redirect($url);
        exit;
    }

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

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

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

        $args = [
            'post_type' => 'attachment',
            'post_status' => 'inherit',
            'posts_per_page' => $limit,
            'fields' => 'ids',
            'orderby' => 'ID',
            'order' => 'DESC',
            'meta_query' => [
                [
                    'key' => MediaFilenameJobs::META_STATUS,
                    'value' => 'applied',
                    'compare' => '=',
                ],
                [
                    'key' => MediaFilenameJobs::META_UNDO,
                    'compare' => 'EXISTS',
                ],
            ],
        ];

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

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

        $q = new \WP_Query($args);
        $ids = is_array($q->posts) ? array_map('intval', $q->posts) : [];
        $ids = array_values(array_filter($ids, static fn ($v) => $v > 0));

        $filename = 'wpsn-filename-rollback-' . gmdate('Ymd-His') . '.csv';

        while (ob_get_level() > 0) {
            ob_end_clean();
        }

        nocache_headers();
        header('Content-Type: text/csv; charset=utf-8');
        header('Content-Disposition: attachment; filename="' . $filename . '"');
        header('Pragma: no-cache');
        header('Expires: 0');

        $out = fopen('php://output', 'w');
        if ($out === false) {
            wp_die(esc_html__('Unable to export CSV.', 'wp-seo-next'));
        }

        fputcsv($out, [
            'attachment_id',
            'attachment_title',
            'current_rel_path',
            'rollback_to_rel_path',
            'undo_entries',
            'do_undo',
        ]);

        foreach ($ids as $id) {
            $title = get_the_title($id);
            $currentRel = (string) get_post_meta($id, '_wp_attached_file', true);

            $undoRaw = (string) get_post_meta($id, MediaFilenameJobs::META_UNDO, true);
            $rollbackTo = '';
            $undoCount = 0;
            if ($undoRaw !== '') {
                $undo = json_decode($undoRaw, true);
                if (is_array($undo) && isset($undo['history']) && is_array($undo['history'])) {
                    $history = $undo['history'];
                    $undoCount = count($history);
                    $last = end($history);
                    if (is_array($last) && isset($last['old_rel'])) {
                        $rollbackTo = (string) $last['old_rel'];
                    }
                }
            }

            fputcsv($out, [
                $id,
                $title,
                $currentRel,
                $rollbackTo,
                $undoCount,
                1,
            ]);
        }

        fclose($out);
        exit;
    }

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

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

        $tmp = (string) $_FILES['csv']['tmp_name'];
        $origName = isset($_FILES['csv']['name']) ? sanitize_text_field((string) $_FILES['csv']['name']) : '';

        $errors = [];
        $csv = $this->readCsvFile($tmp, $errors);

        $header = $csv['header'];
        $rows = $csv['rows'];

        $actionColumn = isset($_POST['action_column']) ? sanitize_key((string) wp_unslash($_POST['action_column'])) : 'do_undo';
        if ($actionColumn === '') {
            $actionColumn = 'do_undo';
        }

        $map = $this->mapFilenameRollbackCsvHeader($header, $actionColumn);
        if (!isset($map['id'])) {
            $errors[] = __('CSV must include an attachment_id (or id) column.', 'wp-seo-next');
        }

        if (!empty($errors)) {
            $this->redirectWithNotice(implode(' ', array_slice($errors, 0, 3)), 'error');
        }

        $ops = [];
        $previewRows = [];
        $skipped = 0;

        foreach ($rows as $ridx => $row) {
            if (!is_array($row)) {
                continue;
            }
            $idCell = $row[$map['id']] ?? '';
            $id = (int) trim((string) $idCell);
            if ($id <= 0) {
                $skipped++;
                continue;
            }

            $doUndo = true;
            if (isset($map['do_undo'])) {
                $val = strtolower(trim((string) ($row[$map['do_undo']] ?? '')));
                $doUndo = in_array($val, ['1', 'yes', 'y', 'true', 'undo', 'rollback'], true);
            }

            if (!$doUndo) {
                $skipped++;
                continue;
            }

            $p = get_post($id);
            if (!$p || $p->post_type !== 'attachment') {
                $errors[] = sprintf(__('Row %d: attachment %d not found.', 'wp-seo-next'), $ridx + 2, $id);
                $skipped++;
                continue;
            }

            $status = (string) get_post_meta($id, MediaFilenameJobs::META_STATUS, true);
            if ($status !== 'applied') {
                $errors[] = sprintf(__('Row %d: attachment %d is not in "applied" status.', 'wp-seo-next'), $ridx + 2, $id);
                $skipped++;
                continue;
            }

            $undoRaw = (string) get_post_meta($id, MediaFilenameJobs::META_UNDO, true);
            if ($undoRaw === '') {
                $errors[] = sprintf(__('Row %d: attachment %d has no undo snapshot.', 'wp-seo-next'), $ridx + 2, $id);
                $skipped++;
                continue;
            }

            $undo = json_decode($undoRaw, true);
            if (!is_array($undo) || !isset($undo['history']) || !is_array($undo['history']) || empty($undo['history'])) {
                $errors[] = sprintf(__('Row %d: attachment %d undo snapshot is invalid.', 'wp-seo-next'), $ridx + 2, $id);
                $skipped++;
                continue;
            }
            $history = $undo['history'];
            $last = end($history);
            if (!is_array($last) || !isset($last['old_rel'])) {
                $errors[] = sprintf(__('Row %d: attachment %d undo snapshot is missing old_rel.', 'wp-seo-next'), $ridx + 2, $id);
                $skipped++;
                continue;
            }

            $currentRel = (string) get_post_meta($id, '_wp_attached_file', true);
            $targetRel = (string) $last['old_rel'];

            $ops[] = ['action' => 'undo', 'id' => $id];
            $previewRows[] = [
                'id' => $id,
                'title' => get_the_title($id),
                'current' => $currentRel,
                'target' => $targetRel,
                'action' => 'undo',
            ];
        }

        $token = sanitize_key(wp_generate_password(12, false, false));
        set_transient($this->filenameRollbackImportPreviewKey($token), [
            'file_name' => $origName,
            'summary' => [
                'total_rows' => count($rows),
                'undo' => count($ops),
                'skipped' => $skipped,
            ],
            'ops' => $ops,
            'rows' => $previewRows,
            'errors' => array_slice($errors, 0, 50),
        ], HOUR_IN_SECONDS);

        $ref = wp_get_referer();
        if (!$ref) {
            $ref = $this->pageUrl([$this->q('mode') => 'filenames', $this->q('view') => 'applied']);
        }
        $url = remove_query_arg(['wpsn_notice', 'wpsn_notice_type', 'fn_rollback_import_preview'], $ref);
        $url = add_query_arg(['fn_rollback_import_preview' => $token], $url);
        wp_safe_redirect($url);
        exit;
    }

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

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

        $key = $this->filenameRollbackImportPreviewKey($token);
        $preview = get_transient($key);
        if (!is_array($preview) || !isset($preview['ops']) || !is_array($preview['ops'])) {
            $this->redirectWithNotice(__('Import preview expired or invalid.', 'wp-seo-next'), 'error');
        }

        $ops = $preview['ops'];
        $fileName = isset($preview['file_name']) ? (string) $preview['file_name'] : '';
        $errs = isset($preview['errors']) && is_array($preview['errors']) ? $preview['errors'] : [];

        $jobs = new MediaFilenameJobs($this->settings, $this->logger);

        $counts = ['undone' => 0, 'failed' => 0, 'skipped' => 0];

        foreach ($ops as $op) {
            if (!is_array($op) || empty($op['action']) || empty($op['id'])) {
                continue;
            }
            $id = (int) $op['id'];
            if ($id <= 0) {
                continue;
            }
            $res = $jobs->undoLast($id);
            if (is_array($res) && !empty($res['ok'])) {
                $counts['undone']++;
            } else {
                $counts['failed']++;
            }
        }

        delete_transient($key);

        // History entry.
        $repo = new FilenameCsvHistoryRepository();
        $repo->append([
            'type' => 'rollback_import',
            'created_at' => time(),
            'user_id' => (int) get_current_user_id(),
            'file_name' => $fileName,
            'summary' => $counts,
            'errors' => $errs,
        ]);

        $msg = sprintf(
            __('Rollback import complete. Undone: %d. Failed: %d.', 'wp-seo-next'),
            (int) $counts['undone'],
            (int) $counts['failed']
        );

        $ref = wp_get_referer();
        if (!$ref) {
            $ref = $this->pageUrl([$this->q('mode') => 'filenames', $this->q('view') => 'applied']);
        }
        $url = remove_query_arg(['wpsn_notice', 'wpsn_notice_type', 'fn_rollback_import_preview'], $ref);
        $url = add_query_arg([
            'wpsn_notice' => $msg,
            'wpsn_notice_type' => 'success',
        ], $url);
        wp_safe_redirect($url);
        exit;
    }

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

        $repo = new FilenameCsvHistoryRepository();
        $repo->clear();

        $this->redirectWithNotice(__('CSV history cleared.', 'wp-seo-next'), 'success');
    }

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

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

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

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

        $q = $this->buildSuggestionsQuery('draft', $search, $sourceFilter, $limit, 1, true, MediaJobs::META_STATUS, MediaJobs::META_SOURCE);
        $ids = is_array($q->posts) ? array_map('intval', $q->posts) : [];
        $ids = array_values(array_filter($ids, static fn ($v) => $v > 0));

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

        set_transient($key, [
            'ids' => $ids,
            'total' => count($ids),
            'processed' => 0,
            'applied' => 0,
            'apply_only_changed' => $applyOnlyChanged ? 1 : 0,
            'created_at' => time(),
        ], HOUR_IN_SECONDS);

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

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

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

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

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

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

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

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

        $jobs = new MediaJobs($this->settings, $this->logger);

        foreach ($batch as $attachmentId) {
            $before = (string) get_post_meta((int) $attachmentId, MediaJobs::META_STATUS, true);
            $jobs->handleApplyAlt(null, ['attachment_id' => (int) $attachmentId, 'apply_only_changed' => $applyOnlyChanged ? 1 : 0], []);
            $after = (string) get_post_meta((int) $attachmentId, MediaJobs::META_STATUS, true);

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

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

        set_transient($key, $state, HOUR_IN_SECONDS);

        $done = empty($ids);
        if ($done) {
            delete_transient($key);
        }

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

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

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

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

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

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

        if ($source === 'local' && !(bool) $this->settings->get('ai.local.enabled', true)) {
            wp_send_json_error(['message' => __('Local suggestions are disabled in Settings.', 'wp-seo-next')], 400);
        }

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

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

        $useVision = !empty($_POST['use_vision']);
        $visionDetail = isset($_POST['vision_detail']) ? sanitize_key((string) wp_unslash($_POST['vision_detail'])) : 'low';
        if (!in_array($visionDetail, ['low', 'high', 'auto'], true)) {
            $visionDetail = 'low';
        }

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

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

        $query = [
            'post_type' => 'attachment',
            'post_status' => 'inherit',
            'post_mime_type' => 'image',
            'posts_per_page' => $limit,
            'fields' => 'ids',
            'orderby' => 'ID',
            'order' => 'DESC',
        ];

        if ($missingAltOnly) {
            $query['meta_query'] = [
                'relation' => 'OR',
                [
                    'key' => '_wp_attachment_image_alt',
                    'compare' => 'NOT EXISTS',
                ],
                [
                    'key' => '_wp_attachment_image_alt',
                    'value' => '',
                    'compare' => '=',
                ],
            ];
        }

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

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

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

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

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

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

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

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

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

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

        $params = $state['params'];
        $source = isset($params['source']) ? sanitize_key((string) $params['source']) : 'local';
        $onlyIfEmpty = !empty($params['only_if_empty']);
        $overwrite = !empty($params['overwrite_suggestions']);
        $useVision = !empty($params['use_vision']);
        $visionDetail = isset($params['vision_detail']) ? sanitize_key((string) $params['vision_detail']) : 'low';
        if (!in_array($visionDetail, ['low', 'high', 'auto'], true)) {
            $visionDetail = 'low';
        }

        $backoffBase = isset($params['backoff_seconds']) ? (int) $params['backoff_seconds'] : 10;
        $backoffBase = max(1, min(120, $backoffBase));
        $backoffCurrent = isset($state['backoff_current']) ? (int) $state['backoff_current'] : $backoffBase;
        $backoffCurrent = max($backoffBase, min(120, $backoffCurrent));

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

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

        $jobs = new MediaJobs($this->settings, $this->logger);

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

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

            $beforeUpdated = (int) get_post_meta($attachmentId, MediaJobs::META_UPDATED, true);

            $res = $jobs->handleGenerateAlt(null, [
                'attachment_id' => $attachmentId,
                'user_id' => (int) get_current_user_id(),
                'source' => $source,
                'only_if_empty' => $onlyIfEmpty,
                'overwrite_suggestions' => $overwrite,
                'use_vision' => $useVision,
                'vision_detail' => $visionDetail,
                'backoff_seconds' => $backoffCurrent,
            ], []);

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

            if ($status === 'retry' && !empty($res['rate_limited'])) {
                $rateLimited = true;
                $retryAfter = isset($res['retry_after']) ? (int) $res['retry_after'] : 0;
                $retryAfter = max($backoffCurrent, $retryAfter);
                $retryAfter = max(1, min(120, $retryAfter));
                $rateLimitMessage = isset($res['error']) ? sanitize_text_field((string) $res['error']) : __('Rate limited.', 'wp-seo-next');

                // Put current + remaining back.
                $remaining = array_slice($batch, $i);
                $remaining = array_values(array_filter(array_map('intval', $remaining), static fn ($v) => $v > 0));
                $ids = array_merge($remaining, $ids);

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

            $afterUpdated = (int) get_post_meta($attachmentId, MediaJobs::META_UPDATED, true);
            $processed++;

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

            $failed++;
            $msg = '';
            if (isset($res['error'])) {
                $msg = sanitize_text_field((string) $res['error']);
            } elseif ($status !== '') {
                $msg = sanitize_text_field($status);
            }
            if ($msg === '') {
                $msg = __('Unknown error', 'wp-seo-next');
            }

            if (!isset($errors[$msg])) {
                if (count($errors) < 8) {
                    $errors[$msg] = 1;
                } else {
                    $errors['Other'] = isset($errors['Other']) ? ((int) $errors['Other'] + 1) : 1;
                }
            } else {
                $errors[$msg] = (int) $errors[$msg] + 1;
            }
        }

        if (!$rateLimited) {
            $state['backoff_current'] = $backoffBase;
        }

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

        set_transient($key, $state, HOUR_IN_SECONDS);

        $done = empty($ids);
        if ($done) {
            delete_transient($key);
        }

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

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

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

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

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

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

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

        // Back-compat: earlier UI used "overwrite_suggestions".
        $overwrite = false;
        if (isset($_POST['overwrite_suggestions']) && (string) wp_unslash($_POST['overwrite_suggestions']) === '1') {
            $overwrite = true;
        }
        if (isset($_POST['overwrite']) && (string) wp_unslash($_POST['overwrite']) === '1') {
            $overwrite = true;
        }
        $maxBaseLen = isset($_POST['max_base_len']) ? (int) $_POST['max_base_len'] : 80;
        $maxBaseLen = max(20, min(120, $maxBaseLen));

        $ids = get_posts([
            'post_type' => 'attachment',
            'post_status' => 'inherit',
            'post_mime_type' => 'image',
            'fields' => 'ids',
            'orderby' => 'ID',
            'order' => 'DESC',
            'posts_per_page' => $limit,
        ]);

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

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

        set_transient($key, [
            'ids' => $ids,
            'total' => count($ids),
            'processed' => 0,
            'generated' => 0,
            'skipped' => 0,
            'failed' => 0,
            'errors' => [],
            'only_if_dirty' => $onlyIfDirty ? 1 : 0,
            'overwrite' => $overwrite ? 1 : 0,
            'max_base_len' => $maxBaseLen,
            'created_at' => time(),
        ], HOUR_IN_SECONDS);

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

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

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

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

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

        $ids = array_values(array_map('intval', $state['ids']));
        $total = isset($state['total']) ? (int) $state['total'] : count($ids);
        $processed = isset($state['processed']) ? (int) $state['processed'] : 0;
        $generated = isset($state['generated']) ? (int) $state['generated'] : 0;
        $skipped = isset($state['skipped']) ? (int) $state['skipped'] : 0;
        $failed = isset($state['failed']) ? (int) $state['failed'] : 0;
        $errors = isset($state['errors']) && is_array($state['errors']) ? $state['errors'] : [];
        $onlyIfDirty = !empty($state['only_if_dirty']);
        $overwrite = !empty($state['overwrite']);
        $maxBaseLen = isset($state['max_base_len']) ? (int) $state['max_base_len'] : 80;

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

        $batch = array_splice($ids, 0, $chunk);
        $jobs = new MediaFilenameJobs($this->settings, $this->logger);

        foreach ($batch as $id) {
            $id = (int) $id;
            if ($id <= 0) {
                continue;
            }

            $beforeUpdated = (int) get_post_meta($id, MediaFilenameJobs::META_UPDATED, true);

            $res = $jobs->handleGenerateFilename(null, [
                'attachment_id' => $id,
                'overwrite_suggestions' => $overwrite ? 1 : 0,
                'only_if_dirty' => $onlyIfDirty ? 1 : 0,
                'max_base_len' => $maxBaseLen,
            ], []);

            $afterUpdated = (int) get_post_meta($id, MediaFilenameJobs::META_UPDATED, true);

            $processed++;

            $status = is_array($res) && isset($res['status']) ? (string) $res['status'] : 'done';
            if ($status === 'failed') {
                $failed++;
                $msg = is_array($res) && isset($res['error']) && $res['error'] !== '' ? (string) $res['error'] : __('Generate failed', 'wp-seo-next');
                if (!isset($errors[$msg])) {
                    if (count($errors) < 8) {
                        $errors[$msg] = 1;
                    } else {
                        $errors['Other'] = isset($errors['Other']) ? ((int) $errors['Other'] + 1) : 1;
                    }
                } else {
                    $errors[$msg] = (int) $errors[$msg] + 1;
                }
                continue;
            }

            if ($afterUpdated > $beforeUpdated) {
                $generated++;
            } else {
                $skipped++;
            }
        }

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

        set_transient($key, $state, HOUR_IN_SECONDS);

        $done = empty($ids);
        if ($done) {
            delete_transient($key);
        }

        wp_send_json_success([
            'done' => $done,
            'processed' => $processed,
            'total' => $total,
            'generated' => $generated,
            'skipped' => $skipped,
            'failed' => $failed,
            'remaining' => count($ids),
            'errors' => $errors,
        ]);
    }

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

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

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

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

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

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

        $replaceInContent = isset($_POST['replace_in_content']) && (string) wp_unslash($_POST['replace_in_content']) === '1';
        $replacePostTypesRaw = isset($_POST['replace_post_types']) ? (string) wp_unslash($_POST['replace_post_types']) : '';
        $replacePostTypes = [];
        if ($replacePostTypesRaw !== '') {
            $replacePostTypes = array_filter(array_map('sanitize_key', explode(',', $replacePostTypesRaw)));
        }

        $q = $this->buildSuggestionsQuery('draft', $search, $sourceFilter, $limit, 1, true, MediaFilenameJobs::META_STATUS, MediaFilenameJobs::META_SOURCE);
        $ids = is_array($q->posts) ? array_map('intval', $q->posts) : [];
        $ids = array_values(array_filter($ids, static fn ($v) => $v > 0));

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

        set_transient($key, [
            'ids' => $ids,
            'total' => count($ids),
            'processed' => 0,
            'applied' => 0,
            'failed' => 0,
            'errors' => [],
            'replace_in_content' => $replaceInContent ? 1 : 0,
            'replace_post_types' => $replacePostTypes,
            'created_at' => time(),
        ], HOUR_IN_SECONDS);

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

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

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

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

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

        $ids = array_values(array_map('intval', $state['ids']));
        $total = isset($state['total']) ? (int) $state['total'] : count($ids);
        $processed = isset($state['processed']) ? (int) $state['processed'] : 0;
        $applied = isset($state['applied']) ? (int) $state['applied'] : 0;
        $failed = isset($state['failed']) ? (int) $state['failed'] : 0;
        $errors = isset($state['errors']) && is_array($state['errors']) ? $state['errors'] : [];
        $replaceInContent = !empty($state['replace_in_content']);
        $replacePostTypes = isset($state['replace_post_types']) && is_array($state['replace_post_types']) ? $state['replace_post_types'] : [];

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

        $batch = array_splice($ids, 0, $chunk);
        $jobs = new MediaFilenameJobs($this->settings, $this->logger);

        foreach ($batch as $id) {
            $id = (int) $id;
            if ($id <= 0) {
                continue;
            }

            $res = $jobs->handleApplyFilename(null, [
                'attachment_id' => $id,
                'replace_in_content' => $replaceInContent ? 1 : 0,
                'replace_post_types' => $replacePostTypes,
            ], []);

            $processed++;

            if (!is_array($res) || ($res['status'] ?? '') === 'failed') {
                $failed++;
                $msg = is_array($res) && isset($res['error']) && $res['error'] !== '' ? (string) $res['error'] : __('Apply failed', 'wp-seo-next');
                if (!isset($errors[$msg])) {
                    if (count($errors) < 8) {
                        $errors[$msg] = 1;
                    } else {
                        $errors['Other'] = isset($errors['Other']) ? ((int) $errors['Other'] + 1) : 1;
                    }
                } else {
                    $errors[$msg] = (int) $errors[$msg] + 1;
                }
                continue;
            }

            $applied++;
        }

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

        set_transient($key, $state, HOUR_IN_SECONDS);

        $done = empty($ids);
        if ($done) {
            delete_transient($key);
        }

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

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

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

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

    /**
     * AJAX: Start "clear filename drafts" session
     */
    public function ajaxFilenameClearDraftsStart(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_send_json_error(['message' => __('You do not have permission.', 'wp-seo-next')], 403);
        }
        check_ajax_referer(self::AJAX_NONCE_ACTION, 'nonce');

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

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

        $q = $this->buildSuggestionsQuery('draft', $search, $sourceFilter, $limit, 1, true, MediaFilenameJobs::META_STATUS, MediaFilenameJobs::META_SOURCE);
        $ids = is_array($q->posts) ? array_map('intval', $q->posts) : [];
        $ids = array_values(array_filter($ids, static fn ($v) => (int) $v > 0));

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

        set_transient($key, [
            'ids' => $ids,
            'total' => count($ids),
            'processed' => 0,
            'cleared' => 0,
            'created_at' => time(),
        ], HOUR_IN_SECONDS);

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

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

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

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

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

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

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

        $batch = array_splice($ids, 0, $chunk);
        foreach ($batch as $id) {
            $id = (int) $id;
            if ($id <= 0) {
                continue;
            }

            // Double-check status hasn't changed.
            $status = (string) get_post_meta($id, MediaFilenameJobs::META_STATUS, true);
            $processed++;
            if ($status !== 'draft') {
                continue;
            }

            delete_post_meta($id, MediaFilenameJobs::META_SUGGESTIONS);
            delete_post_meta($id, MediaFilenameJobs::META_STATUS);
            delete_post_meta($id, MediaFilenameJobs::META_SOURCE);
            delete_post_meta($id, MediaFilenameJobs::META_UPDATED);
            $cleared++;
        }

        $state['ids'] = $ids;
        $state['processed'] = $processed;
        $state['cleared'] = $cleared;
        set_transient($key, $state, HOUR_IN_SECONDS);

        $done = empty($ids);
        if ($done) {
            delete_transient($key);
        }

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

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

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

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

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

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

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

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

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

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

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

    /**
     * @param array<int,string> $errors
     * @return array{header:array<int,string>,rows:array<int,array<int,string>>}
     */
    private function readCsvFile(string $filePath, array &$errors): array
    {
        if (!is_readable($filePath)) {
            $errors[] = __('Cannot read uploaded file.', 'wp-seo-next');
            return ['header' => [], 'rows' => []];
        }

        $h = fopen($filePath, 'r');
        if ($h === false) {
            $errors[] = __('Cannot open uploaded file.', 'wp-seo-next');
            return ['header' => [], 'rows' => []];
        }

        $firstLine = fgets($h);
        if ($firstLine === false) {
            fclose($h);
            $errors[] = __('CSV file is empty.', 'wp-seo-next');
            return ['header' => [], 'rows' => []];
        }
        $delimiter = $this->detectCsvDelimiter($firstLine);
        rewind($h);

        $rawHeader = fgetcsv($h, 0, $delimiter, '"');
        if (!is_array($rawHeader)) {
            fclose($h);
            $errors[] = __('CSV header is invalid.', 'wp-seo-next');
            return ['header' => [], 'rows' => []];
        }

        // Normalize header: trim, lower, strip UTF-8 BOM.
        $header = [];
        foreach ($rawHeader as $cell) {
            $cell = (string) $cell;
            $cell = preg_replace('/^\xEF\xBB\xBF/', '', $cell);
            $header[] = strtolower(trim($cell));
        }

        $rows = [];
        while (($row = fgetcsv($h, 0, $delimiter, '"')) !== false) {
            if (!is_array($row)) {
                continue;
            }
            // Keep strings.
            $rows[] = array_map(static fn($v) => is_string($v) ? $v : (string) $v, $row);
        }

        fclose($h);
        return ['header' => $header, 'rows' => $rows];
    }

    private function detectCsvDelimiter(string $line): string
    {
        $candidates = [',', ';', "\t"];
        $best = ',';
        $bestCount = -1;
        foreach ($candidates as $d) {
            $count = substr_count($line, $d);
            if ($count > $bestCount) {
                $bestCount = $count;
                $best = $d;
            }
        }
        return $best;
    }

    /**
     * @param array<int,string> $header
     * @return array<string,int>
     */
    private function mapFilenameCsvHeader(array $header): array
    {
        $map = [];

        $aliases = [
            'id' => ['attachment_id', 'id', 'post_id'],
            'suggested_basename' => ['suggested_basename', 'new_basename', 'basename', 'suggested_filename', 'new_filename', 'filename'],
            'suggested_rel_path' => ['suggested_rel_path', 'new_rel_path', 'rel_path', 'suggested_rel'],
        ];

        foreach ($header as $idx => $name) {
            $name = strtolower(trim((string) $name));
            foreach ($aliases as $key => $names) {
                if (in_array($name, $names, true) && !isset($map[$key])) {
                    $map[$key] = (int) $idx;
                }
            }
        }

        return $map;
    }

    /**
     * @param array<int,string> $header
     * @return array<string,int>
     */
    private function mapFilenameRollbackCsvHeader(array $header, string $actionColumn = 'do_undo'): array
    {
        $map = [];

        $actionColumn = strtolower(trim($actionColumn));
        if ($actionColumn === '') {
            $actionColumn = 'do_undo';
        }

        $aliases = [
            'id' => ['attachment_id', 'id', 'post_id'],
            'do_undo' => array_values(array_unique(array_filter([
                'do_undo',
                'undo',
                'action',
                'rollback',
                'apply',
                $actionColumn,
            ]))),
        ];

        foreach ($header as $idx => $name) {
            $name = strtolower(trim((string) $name));
            foreach ($aliases as $key => $names) {
                if (in_array($name, $names, true) && !isset($map[$key])) {
                    $map[$key] = (int) $idx;
                }
            }
        }

        return $map;
    }

    /**
     * Normalize an imported filename or rel_path into a safe rel_path within the current attachment directory.
     */
    private function normalizeImportedFilename(int $attachmentId, string $rawValue): string
    {
        $rawValue = trim($rawValue);
        if ($rawValue === '') {
            return '';
        }

        // Always treat input as basename; ignore any directory provided in CSV.
        $basenameInput = wp_basename($rawValue);
        $basenameInput = trim($basenameInput);
        if ($basenameInput === '') {
            return '';
        }

        $currentRel = (string) get_post_meta($attachmentId, '_wp_attached_file', true);
        $currentExt = strtolower((string) pathinfo($currentRel, PATHINFO_EXTENSION));
        if ($currentExt === '') {
            $abs = (string) get_attached_file($attachmentId);
            $currentExt = strtolower((string) pathinfo($abs, PATHINFO_EXTENSION));
        }

        $dirRel = '';
        if ($currentRel !== '') {
            $d = dirname($currentRel);
            if ($d !== '.' && $d !== '/' && $d !== '\\') {
                $dirRel = trim(str_replace('\\', '/', (string) $d), '/');
            }
        }

        // Force same extension as current file.
        $baseNoExt = (string) pathinfo($basenameInput, PATHINFO_FILENAME);
        $baseNoExt = trim($baseNoExt);
        if ($baseNoExt === '') {
            return '';
        }

        $sanBase = sanitize_file_name($baseNoExt);
        $sanBase = trim($sanBase, " .\t\n\r\0\x0B");
        if ($sanBase === '') {
            return '';
        }

        $ext = $currentExt !== '' ? $currentExt : strtolower((string) pathinfo($basenameInput, PATHINFO_EXTENSION));
        if ($ext === '') {
            return '';
        }

        $finalBase = $sanBase . '.' . $ext;
        $finalBase = sanitize_file_name($finalBase);
        if ($finalBase === '' || strpos($finalBase, '.') === false) {
            return '';
        }

        $rel = $dirRel !== '' ? ($dirRel . '/' . $finalBase) : $finalBase;
        return ltrim($rel, '/');
    }

    private function renderNotices(): void
    {
        if (!isset($_GET['wpsn_notice'])) {
            return;
        }

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

        printf(
            '<div class="notice notice-%s is-dismissible"><p>%s</p></div>',
            esc_attr($type),
            esc_html($msg)
        );
    }

    /**
     * @param array<string,string> $extraArgs
     */
    private function redirectWithNotice(string $msg, string $type = 'success', array $extraArgs = []): void
    {
        $args = array_merge(
            $extraArgs,
            [
                'wpsn_notice' => $msg,
                'wpsn_notice_type' => $type,
            ]
        );

        $ref = wp_get_referer();
        if (is_string($ref) && $ref !== '') {
            $ref = remove_query_arg(['wpsn_notice', 'wpsn_notice_type'], $ref);
            $url = add_query_arg($args, $ref);
        } else {
            $url = $this->pageUrl($args);
        }

        wp_safe_redirect($url);
        exit;
    }

    /**
     * @param array<string,string> $args
     */
    private function pageUrl(array $args = []): string
    {
        $base = admin_url('tools.php?page=' . $this->basePageSlug);

        $merged = array_merge($this->basePageArgs, $_GET, $args);
        unset($merged['page']);

        return add_query_arg($merged, $base);
    }

    /**
     * Build a URL for this screen without preserving current query args.
     * Useful for "Reset" links.
     *
     * @param array<string,string> $args
     */
    private function pageUrlClean(array $args = []): string
    {
        $base = admin_url('tools.php?page=' . $this->basePageSlug);
        $merged = array_merge($this->basePageArgs, $args);
        unset($merged['page']);
        return add_query_arg($merged, $base);
    }

    private function q(string $key): string
    {
        return $this->paramPrefix . $key;
    }
}
