<?php

namespace WPSN\Modules\ImportExport\Admin;

use WPSN\Admin\SettingsRepository;
use WPSN\Core\Container;
use WPSN\Core\Capabilities;
use WPSN\Core\Branding;
use WPSN\Core\Logger;
use WPSN\Core\UndoRuns;
use WPSN\Modules\ArchiveControls\ArchiveControlsModule;
use WPSN\Modules\HeadCleanup\HeadCleanupProfile;
use WPSN\Modules\ImportExport\ImportAssistRepository;
use WPSN\Modules\ImportExport\ImportAssistSummaryBuilder;
use WPSN\Modules\ImportExport\ImportAssistChecklistBuilder;
use WPSN\Modules\ImportExport\ImportAssistChecklistStateRepository;
use WPSN\Modules\ImportExport\ImportAssistFilterPresetRepository;
use WPSN\Modules\ImportExport\ImportAssistGuidedReviewPlanner;
use WPSN\Modules\ImportExport\ImportAssistDiffPresenter;
use WPSN\Modules\ImportExport\ImportAssistCsvExporter;
use WPSN\Modules\IndexNow\IndexNowSecretsRepository;
use WPSN\Modules\Robots\RobotsDisallowRules;
use WPSN\Modules\SearchUrls\SearchUrlsModule;
use WPSN\Modules\Stability\ConfigSnapshotService;
use WPSN\SEO\MetaKeys;

defined('ABSPATH') || exit;

final class ImportExportAdmin
{
    private const PAGE_SLUG = 'wpsn-import-export';
    private const JOB_TTL   = 2 * HOUR_IN_SECONDS;
    private const BATCH_SIZE = 200;

    private Container $container;
    private Logger $logger;
    private SettingsRepository $settingsRepo;
    private ImportAssistRepository $importAssistRepo;
    private ImportAssistSummaryBuilder $importAssistBuilder;
    private ImportAssistChecklistBuilder $importAssistChecklistBuilder;
    private ImportAssistChecklistStateRepository $importAssistChecklistStateRepo;
    private ImportAssistFilterPresetRepository $importAssistFilterPresetRepo;
    private ImportAssistGuidedReviewPlanner $importAssistGuidedReviewPlanner;
    private ImportAssistDiffPresenter $importAssistDiffPresenter;

    public function __construct(Container $container, Logger $logger)
    {
        $this->container    = $container;
        $this->logger       = $logger;
        $this->settingsRepo = $container->get('settings_repo');
        $this->importAssistRepo = new ImportAssistRepository();
        $this->importAssistBuilder = new ImportAssistSummaryBuilder();
        $this->importAssistChecklistBuilder = new ImportAssistChecklistBuilder();
        $this->importAssistChecklistStateRepo = new ImportAssistChecklistStateRepository();
        $this->importAssistFilterPresetRepo = new ImportAssistFilterPresetRepository();
        $this->importAssistGuidedReviewPlanner = new ImportAssistGuidedReviewPlanner();
        $this->importAssistDiffPresenter = new ImportAssistDiffPresenter();
    }

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

        add_action('admin_post_wpsn_export_settings', [$this, 'handleExportSettings']);
        add_action('admin_post_wpsn_import_settings', [$this, 'handleImportSettings']);
        add_action('admin_post_wpsn_clear_import_assist', [$this, 'handleClearImportAssist']);
        add_action('admin_post_wpsn_import_assist_task_state', [$this, 'handleImportAssistTaskState']);
        add_action('admin_post_wpsn_import_assist_task_state_link', [$this, 'handleImportAssistTaskStateLink']);
        add_action('admin_post_wpsn_import_assist_bulk_state', [$this, 'handleImportAssistBulkState']);
        add_action('admin_post_wpsn_import_assist_filter_preset', [$this, 'handleImportAssistFilterPreset']);
        add_action('admin_post_wpsn_export_import_assist', [$this, 'handleExportImportAssist']);
        add_action('admin_post_wpsn_export_import_assist_csv', [$this, 'handleExportImportAssistCsv']);

        // Import global settings from other plugins' export files.
        add_action('admin_post_wpsn_import_yoast_settings', [$this, 'handleImportYoastSettings']);
        add_action('admin_post_wpsn_import_rankmath_settings', [$this, 'handleImportRankMathSettings']);
        add_action('admin_post_wpsn_import_seopress_settings', [$this, 'handleImportSeopressSettings']);
        add_action('admin_post_wpsn_import_aioseo_settings', [$this, 'handleImportAioseoSettings']);

        // Import global settings directly from installed plugins (no file export).
        add_action('admin_post_wpsn_import_yoast_settings_site', [$this, 'handleImportYoastSettingsFromSite']);
        add_action('admin_post_wpsn_import_rankmath_settings_site', [$this, 'handleImportRankMathSettingsFromSite']);
        add_action('admin_post_wpsn_import_seopress_settings_site', [$this, 'handleImportSeopressSettingsFromSite']);
        add_action('admin_post_wpsn_import_aioseo_settings_site', [$this, 'handleImportAioseoSettingsFromSite']);

        add_action('admin_post_wpsn_start_migration', [$this, 'handleStartMigration']);
        add_action('admin_post_wpsn_process_migration', [$this, 'handleProcessMigration']);
        add_action('admin_post_wpsn_cancel_migration', [$this, 'handleCancelMigration']);

        // Migration report downloads.
        add_action('admin_post_wpsn_export_migration_report', [$this, 'handleExportMigrationReport']);
        add_action('admin_post_wpsn_export_migration_report_csv', [$this, 'handleExportMigrationReportCsv']);

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

    public function addMenu(): void
    {
        add_management_page(
            __('SEO Import/Export', 'wp-seo-next'),
            __('SEO Import/Export', 'wp-seo-next'),
            Capabilities::CAP_TOOLS,
            self::PAGE_SLUG,
            [$this, 'renderPage']
        );
    }

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

        $notice = sanitize_key((string) wp_unslash($_GET['wpsn_notice']));
        $count = isset($_GET['wpsn_count']) ? max(0, (int) wp_unslash($_GET['wpsn_count'])) : 0;

        if ($notice === 'import_assist_bulk_checked') {
            $message = $count === 1
                ? __('Marked 1 Import Assist checklist item as checked.', 'wp-seo-next')
                : sprintf(__('Marked %d Import Assist checklist items as checked.', 'wp-seo-next'), max(0, $count));
            echo '<div class="notice notice-success is-dismissible"><p>' . esc_html($message) . '</p></div>';
            return;
        }

        if ($notice === 'import_assist_bulk_reopened') {
            $message = $count === 1
                ? __('Moved 1 Import Assist checklist item back to review.', 'wp-seo-next')
                : sprintf(__('Moved %d Import Assist checklist items back to review.', 'wp-seo-next'), max(0, $count));
            echo '<div class="notice notice-success is-dismissible"><p>' . esc_html($message) . '</p></div>';
            return;
        }

        $messages = [
            'settings_imported' => __('Settings imported.', 'wp-seo-next'),
            'undo_done' => __('Undo completed.', 'wp-seo-next'),
            'undo_already' => __('This action was already undone.', 'wp-seo-next'),
            'yoast_imported' => __('Yoast settings imported.', 'wp-seo-next'),
            'rankmath_imported' => __('Rank Math settings imported.', 'wp-seo-next'),
            'seopress_imported' => __('SEOPress settings imported.', 'wp-seo-next'),
            'aioseo_imported' => __('AIOSEO settings imported.', 'wp-seo-next'),
            'migration_started' => __('Migration started.', 'wp-seo-next'),
            'migration_done'    => __('Migration completed.', 'wp-seo-next'),
            'migration_canceled' => __('Migration canceled.', 'wp-seo-next'),
            'import_assist_task_checked' => __('Import Assist checklist item marked as checked.', 'wp-seo-next'),
            'import_assist_task_reopened' => __('Import Assist checklist item moved back to review.', 'wp-seo-next'),
            'import_assist_preset_saved' => __('Import Assist filter preset saved.', 'wp-seo-next'),
            'import_assist_preset_deleted' => __('Import Assist filter preset deleted.', 'wp-seo-next'),
            'import_assist_default_preset_saved' => __('Import Assist default preset updated.', 'wp-seo-next'),
            'import_assist_default_preset_cleared' => __('Import Assist default preset cleared.', 'wp-seo-next'),
        ];

        $errors = [
            'import_settings_invalid' => __('Invalid settings file.', 'wp-seo-next'),
            'import_settings_failed'  => __('Failed to import settings.', 'wp-seo-next'),
            'undo_failed' => __('Undo failed.', 'wp-seo-next'),
            'yoast_import_invalid' => __('Invalid Yoast export file.', 'wp-seo-next'),
            'yoast_import_failed'  => __('Failed to import Yoast settings.', 'wp-seo-next'),
            'rankmath_import_invalid' => __('Invalid Rank Math export file.', 'wp-seo-next'),
            'rankmath_import_failed'  => __('Failed to import Rank Math settings.', 'wp-seo-next'),
            'seopress_import_invalid' => __('Invalid SEOPress export file.', 'wp-seo-next'),
            'seopress_import_failed'  => __('Failed to import SEOPress settings.', 'wp-seo-next'),
            'aioseo_import_invalid' => __('Invalid AIOSEO export file.', 'wp-seo-next'),
            'aioseo_import_failed'  => __('Failed to import AIOSEO settings.', 'wp-seo-next'),
            'migration_failed'        => __('Migration failed.', 'wp-seo-next'),
            'migration_job_missing'   => __('Migration job not found (expired). Please start again.', 'wp-seo-next'),
            'import_assist_preset_failed' => __('Could not save the Import Assist filter preset.', 'wp-seo-next'),
        ];

        if (isset($messages[$notice])) {
            echo '<div class="notice notice-success is-dismissible"><p>' . esc_html($messages[$notice]) . '</p></div>';
            return;
        }

        if (isset($errors[$notice])) {
            echo '<div class="notice notice-error is-dismissible"><p>' . esc_html($errors[$notice]) . '</p></div>';
        }
    }


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

        check_admin_referer('wpsn_clear_import_assist');

        $this->importAssistRepo->clear();
        $this->importAssistChecklistStateRepo->clear();

        wp_safe_redirect($this->pageUrl(['cleared_assist' => '1']));
        exit;
    }

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

        check_admin_referer('wpsn_import_assist_task_state');

        $entryId = isset($_POST['entry_id']) ? (string) wp_unslash($_POST['entry_id']) : '';
        $taskKey = isset($_POST['task_key']) ? (string) wp_unslash($_POST['task_key']) : '';
        $mode = isset($_POST['mode']) ? sanitize_key((string) wp_unslash($_POST['mode'])) : 'check';
        $redirectTo = isset($_POST['redirect_to']) ? (string) wp_unslash($_POST['redirect_to']) : $this->pageUrl();
        $taskUrl = isset($_POST['task_url']) ? (string) wp_unslash($_POST['task_url']) : '';

        $this->applyImportAssistTaskState($entryId, $taskKey, $mode, $redirectTo, $taskUrl);
    }


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

        check_admin_referer('wpsn_import_assist_task_state_link');

        $entryId = isset($_GET['entry_id']) ? (string) wp_unslash($_GET['entry_id']) : '';
        $taskKey = isset($_GET['task_key']) ? (string) wp_unslash($_GET['task_key']) : '';
        $mode = isset($_GET['mode']) ? sanitize_key((string) wp_unslash($_GET['mode'])) : 'check';
        $redirectTo = isset($_GET['redirect_to']) ? (string) wp_unslash($_GET['redirect_to']) : $this->pageUrl();
        $taskUrl = isset($_GET['task_url']) ? (string) wp_unslash($_GET['task_url']) : '';

        $this->applyImportAssistTaskState($entryId, $taskKey, $mode, $redirectTo, $taskUrl);
    }

    private function applyImportAssistTaskState(string $entryId, string $taskKey, string $mode, string $redirectTo, string $taskUrl = ''): void
    {
        $checked = !in_array($mode, ['reopen', 'uncheck'], true);
        $this->importAssistChecklistStateRepo->setChecked($entryId, $taskKey, $checked);

        $notice = $checked ? 'import_assist_task_checked' : 'import_assist_task_reopened';
        if ($mode === 'check_and_open' && $taskUrl !== '') {
            $target = add_query_arg(['wpsn_notice' => $notice], $taskUrl);
            wp_safe_redirect($target);
            exit;
        }

        $target = add_query_arg(['wpsn_notice' => $notice], $redirectTo);
        wp_safe_redirect($target);
        exit;
    }

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

        check_admin_referer('wpsn_import_assist_bulk_state');

        $mode = isset($_POST['mode']) ? sanitize_key((string) wp_unslash($_POST['mode'])) : 'check';
        $redirectTo = isset($_POST['redirect_to']) ? (string) wp_unslash($_POST['redirect_to']) : $this->pageUrl();
        $taskRefs = isset($_POST['task_refs']) ? wp_unslash($_POST['task_refs']) : [];
        $checked = !in_array($mode, ['reopen', 'uncheck'], true);
        $count = 0;

        foreach ($this->groupImportAssistTaskRefs(is_array($taskRefs) ? $taskRefs : []) as $entryId => $taskKeys) {
            $count += $this->importAssistChecklistStateRepo->setCheckedMany($entryId, $taskKeys, $checked);
        }

        $notice = $checked ? 'import_assist_bulk_checked' : 'import_assist_bulk_reopened';
        $target = add_query_arg([
            'wpsn_notice' => $notice,
            'wpsn_count' => $count,
        ], $redirectTo);
        wp_safe_redirect($target);
        exit;
    }

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

        check_admin_referer('wpsn_import_assist_filter_preset');

        $mode = isset($_POST['mode']) ? sanitize_key((string) wp_unslash($_POST['mode'])) : '';
        $presetSlug = isset($_POST['preset_slug']) ? sanitize_key((string) wp_unslash($_POST['preset_slug'])) : '';
        $redirectTo = isset($_POST['redirect_to']) ? (string) wp_unslash($_POST['redirect_to']) : $this->pageUrl();

        switch ($mode) {
            case 'save':
                $label = isset($_POST['preset_label']) ? sanitize_text_field((string) wp_unslash($_POST['preset_label'])) : '';
                $status = isset($_POST['status']) ? sanitize_key((string) wp_unslash($_POST['status'])) : 'all';
                $priority = isset($_POST['priority']) ? sanitize_key((string) wp_unslash($_POST['priority'])) : 'all';
                $focus = isset($_POST['focus']) ? sanitize_key((string) wp_unslash($_POST['focus'])) : 'all';
                $savedSlug = $this->importAssistFilterPresetRepo->save($label, $status, $priority, $focus);
                if ($savedSlug === '') {
                    wp_safe_redirect(add_query_arg(['wpsn_notice' => 'import_assist_preset_failed'], $redirectTo));
                    exit;
                }
                wp_safe_redirect(add_query_arg(['wpsn_notice' => 'import_assist_preset_saved'], $this->pageUrl(['assist_preset' => $savedSlug])));
                exit;

            case 'delete':
                $deleted = $this->importAssistFilterPresetRepo->delete($presetSlug);
                $notice = $deleted ? 'import_assist_preset_deleted' : 'import_assist_preset_failed';
                wp_safe_redirect(add_query_arg(['wpsn_notice' => $notice], $this->pageUrl(['assist_preset' => 'all'])));
                exit;

            case 'set_default':
                if ($this->resolveImportAssistPreset($presetSlug) === null) {
                    wp_safe_redirect(add_query_arg(['wpsn_notice' => 'import_assist_preset_failed'], $redirectTo));
                    exit;
                }
                $this->importAssistFilterPresetRepo->setDefault($presetSlug);
                wp_safe_redirect(add_query_arg(['wpsn_notice' => 'import_assist_default_preset_saved'], $this->pageUrl(['assist_preset' => $presetSlug])));
                exit;

            case 'clear_default':
                $this->importAssistFilterPresetRepo->clearDefault();
                wp_safe_redirect(add_query_arg(['wpsn_notice' => 'import_assist_default_preset_cleared'], $redirectTo));
                exit;
        }

        wp_safe_redirect(add_query_arg(['wpsn_notice' => 'import_assist_preset_failed'], $redirectTo));
        exit;
    }



    private function renderImportAssistSection(): void
    {
        $entries = $this->importAssistRepo->all(6);
        $filters = $this->getImportAssistFilters();
        $filtersActive = $this->importAssistFiltersActive($filters);

        echo '<hr />';
        echo '<h2>' . esc_html__('Import Assist', 'wp-seo-next') . '</h2>';
        echo '<p>' . esc_html__('See what was imported, what was skipped, and what still needs manual review after recent settings imports or content migrations.', 'wp-seo-next') . '</p>';

        if (!empty($_GET['cleared_assist']) && (string) wp_unslash($_GET['cleared_assist']) === '1') {
            echo '<div class="notice notice-success inline"><p>' . esc_html__('Import Assist history cleared.', 'wp-seo-next') . '</p></div>';
        }

        echo '<p class="description">' . esc_html__('This is a compact, action-oriented view on top of the raw import reports. Use it to spot archive settings, schema defaults, redirects, breadcrumb settings, or other items that may still need manual cleanup. Each card can also export its own JSON evidence bundle for debugging or handoff.', 'wp-seo-next') . '</p>';

        $globalTaskRefs = [];
        $globalHighTaskRefs = [];
        $visibleEntries = [];
        $allTaskCount = 0;
        $visibleTaskCount = 0;

        if (!empty($entries)) {
            foreach ($entries as $entry) {
                if (!is_array($entry)) {
                    continue;
                }

                $entryId = $this->importAssistRepo->identify($entry);
                $reviewUrl = $this->importAssistReviewUrl($entry);
                $checklist = $this->importAssistChecklistBuilder->build($entry, $reviewUrl);
                $checklist = $this->importAssistChecklistStateRepo->apply($checklist, $entryId);
                $filteredChecklist = $this->filterImportAssistChecklist($checklist, $filters);

                $allTaskCount += count($checklist);
                if ($filtersActive && empty($filteredChecklist)) {
                    continue;
                }

                $visibleEntries[] = [
                    'entry' => $entry,
                    'checklist' => $checklist,
                    'visible_checklist' => $filteredChecklist,
                ];
                $visibleTaskCount += count($filteredChecklist);
                $globalTaskRefs = array_merge($globalTaskRefs, $this->buildImportAssistTaskRefs($entryId, $filteredChecklist));
                $globalHighTaskRefs = array_merge($globalHighTaskRefs, $this->buildImportAssistTaskRefs($entryId, $filteredChecklist, 'high'));
            }
        }

        $this->renderImportAssistFilterControls($filters, count($visibleEntries), $visibleTaskCount, $allTaskCount);

        $nextVisibleTask = $this->importAssistGuidedReviewPlanner->nextTaskAcrossEntries(array_map(function (array $entryView): array {
            $entry = isset($entryView['entry']) && is_array($entryView['entry']) ? $entryView['entry'] : [];
            return [
                'entry_id' => $this->importAssistRepo->identify($entry),
                'entry_title' => isset($entry['title']) ? (string) $entry['title'] : '',
                'tasks' => isset($entryView['visible_checklist']) && is_array($entryView['visible_checklist']) ? $entryView['visible_checklist'] : [],
            ];
        }, $visibleEntries));

        echo '<div style="margin:10px 0 14px;display:flex;flex-wrap:wrap;gap:8px;align-items:center;">';
        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;margin:0;">';
        echo '<input type="hidden" name="action" value="wpsn_clear_import_assist" />';
        wp_nonce_field('wpsn_clear_import_assist');
        submit_button(__('Clear history', 'wp-seo-next'), 'secondary', 'submit', false, ['onclick' => "return confirm('". esc_js(__('Clear recent Import Assist cards?', 'wp-seo-next')) ."');"]);
        echo '</form>';
        echo '<a class="button button-secondary" href="' . esc_url(admin_url('tools.php?page=wpsn-tasks&tab=imports')) . '">' . esc_html__('Sync open tasks to Tasks Hub', 'wp-seo-next') . '</a>';
        echo '<a class="button button-secondary" href="' . esc_url(admin_url('tools.php?page=wpsn-redirect-migration')) . '">' . esc_html__('Open Redirect Migration', 'wp-seo-next') . '</a>';
        if (is_array($nextVisibleTask) && !empty($nextVisibleTask['url'])) {
            $resumeLabel = !empty($nextVisibleTask['entry_title'])
                ? sprintf(__('Resume guided review · %s', 'wp-seo-next'), (string) $nextVisibleTask['entry_title'])
                : __('Resume guided review', 'wp-seo-next');
            echo '<a class="button button-primary" href="' . esc_url((string) $nextVisibleTask['url']) . '" title="' . esc_attr__('Open the next visible unchecked checklist item, prioritizing high-priority tasks first.', 'wp-seo-next') . '">' . esc_html($resumeLabel) . '</a>';
        }
        if (!empty($globalTaskRefs)) {
            $this->renderImportAssistBulkForm($globalTaskRefs, 'check', __('Mark all visible checked', 'wp-seo-next'), __('Mark every visible checklist task as checked across recent Import Assist cards.', 'wp-seo-next'));
            if (!empty($globalHighTaskRefs)) {
                $this->renderImportAssistBulkForm($globalHighTaskRefs, 'check', __('Check visible high priority', 'wp-seo-next'), __('Mark only visible high-priority checklist tasks as checked.', 'wp-seo-next'));
            }
            $this->renderImportAssistBulkForm($globalTaskRefs, 'reopen', __('Reset visible checklist progress', 'wp-seo-next'), __('Move visible checklist tasks back into review.', 'wp-seo-next'));
        }
        echo '</div>';

        if (empty($entries)) {
            echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;border-radius:4px;max-width:960px;">';
            echo '<p style="margin:0;"><strong>' . esc_html__('No recent import reports yet.', 'wp-seo-next') . '</strong></p>';
            echo '<p class="description" style="margin:6px 0 0;">' . esc_html__('Run a settings import or content migration and this section will summarize applied items, skipped items, and manual follow-ups.', 'wp-seo-next') . '</p>';
            echo '</div>';
            return;
        }

        if (empty($visibleEntries)) {
            echo '<div style="padding:12px;border:1px solid #dcdcde;background:#fff;border-radius:4px;max-width:960px;">';
            echo '<p style="margin:0;"><strong>' . esc_html__('No Import Assist tasks match the current filters.', 'wp-seo-next') . '</strong></p>';
            echo '<p class="description" style="margin:6px 0 0;">' . esc_html__('Try a different checklist status or priority filter, or reset the filters to show all recent import cards again.', 'wp-seo-next') . '</p>';
            echo '</div>';
            return;
        }

        echo '<div class="wpsn-import-assist-cards" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:12px;max-width:1200px;">';
        foreach ($visibleEntries as $entryView) {
            if (!is_array($entryView) || empty($entryView['entry']) || !is_array($entryView['entry'])) {
                continue;
            }
            $this->renderImportAssistEntry(
                $entryView['entry'],
                isset($entryView['checklist']) && is_array($entryView['checklist']) ? $entryView['checklist'] : null,
                isset($entryView['visible_checklist']) && is_array($entryView['visible_checklist']) ? $entryView['visible_checklist'] : null,
                $filters
            );
        }
        echo '</div>';
    }

    /**
     * @param array<string,mixed> $entry
     * @param array<int,array<string,mixed>>|null $resolvedChecklist
     * @param array<int,array<string,mixed>>|null $visibleChecklist
     * @param array<string,string> $filters
     */
    private function renderImportAssistEntry(array $entry, ?array $resolvedChecklist = null, ?array $visibleChecklist = null, array $filters = []): void
    {
        $entryId = $this->importAssistRepo->identify($entry);
        $summary = isset($entry['summary']) && is_array($entry['summary']) ? $entry['summary'] : [];
        $items = isset($entry['items']) && is_array($entry['items']) ? $entry['items'] : [];
        $sourceLabel = isset($entry['source_label']) ? (string) $entry['source_label'] : __('Import', 'wp-seo-next');
        $title = isset($entry['title']) ? (string) $entry['title'] : __('Import report', 'wp-seo-next');
        $context = isset($entry['context']) ? (string) $entry['context'] : '';
        $reviewUrl = $this->importAssistReviewUrl($entry);
        $exportUrl = $this->importAssistExportUrl($entryId);
        $exportCsvUrl = $this->importAssistExportCsvUrl($entryId);
        $found = isset($summary['found']) ? (int) $summary['found'] : 0;
        $applied = isset($summary['applied']) ? (int) $summary['applied'] : 0;
        $skipped = isset($summary['skipped']) ? (int) $summary['skipped'] : 0;
        $notMapped = isset($summary['not_mapped']) ? (int) $summary['not_mapped'] : 0;
        $warnings = isset($summary['warnings']) ? (int) $summary['warnings'] : 0;
        $manual = isset($summary['manual']) ? (int) $summary['manual'] : 0;
        $sourceFoundItems = isset($items['source_found']) && is_array($items['source_found']) ? $items['source_found'] : [];
        $warningItems = isset($items['warnings']) && is_array($items['warnings']) ? $items['warnings'] : [];
        $notMappedItems = isset($items['not_mapped']) && is_array($items['not_mapped']) ? $items['not_mapped'] : [];
        $manualActions = isset($items['manual_actions']) && is_array($items['manual_actions']) ? $items['manual_actions'] : [];
        $detailMode = $this->importAssistDetailMode($filters);
        $detailToggleUrl = $this->currentImportAssistPageUrl([
            'assist_detail' => $detailMode === 'full' ? 'compact' : 'full',
            'assist_entry' => $entryId,
        ]) . '#wpsn-import-assist-' . rawurlencode($entryId);
        $entryDiff = $this->importAssistDiffPresenter->build($entry);
        $warningGroups = isset($entryDiff['warning_groups']) && is_array($entryDiff['warning_groups']) ? $entryDiff['warning_groups'] : [];
        $warningSummary = isset($entryDiff['warning_summary']) ? trim((string) $entryDiff['warning_summary']) : '';

        $activeEntry = isset($_GET['assist_entry']) ? sanitize_key((string) wp_unslash($_GET['assist_entry'])) : '';
        $cardBorder = $activeEntry === $entryId ? '#2563eb' : '#dcdcde';
        $cardShadow = $activeEntry === $entryId ? '0 0 0 2px rgba(37,99,235,.12)' : 'none';
        echo '<div id="wpsn-import-assist-' . esc_attr($entryId) . '" style="padding:14px;border:1px solid ' . esc_attr($cardBorder) . ';background:#fff;border-radius:6px;box-shadow:' . esc_attr($cardShadow) . ';">';
        echo '<div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start;">';
        echo '<div>';
        echo '<p style="margin:0 0 4px;"><strong>' . esc_html($title) . '</strong></p>';
        echo '<p class="description" style="margin:0;">' . esc_html($sourceLabel);
        if ($context !== '') {
            echo ' · ' . esc_html($context === 'site' ? __('from installed plugin', 'wp-seo-next') : __('from export file', 'wp-seo-next'));
        }
        echo '</p>';
        echo '</div>';
        echo '<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end;">';
        echo '<a class="button button-small" href="' . esc_url($detailToggleUrl) . '">' . esc_html($detailMode === 'full' ? __('Compact', 'wp-seo-next') : __('Full detail', 'wp-seo-next')) . '</a>';
        if ($reviewUrl !== '') {
            echo '<a class="button button-small" href="' . esc_url($reviewUrl) . '">' . esc_html__('Review', 'wp-seo-next') . '</a>';
        }
        if ($exportUrl !== '') {
            echo '<a class="button button-small" href="' . esc_url($exportUrl) . '">' . esc_html__('Export JSON', 'wp-seo-next') . '</a>';
        }
        if ($exportCsvUrl !== '') {
            echo '<a class="button button-small" href="' . esc_url($exportCsvUrl) . '">' . esc_html__('Export CSV', 'wp-seo-next') . '</a>';
        }
        echo '</div>';
        echo '</div>';

        $chips = [];
        if ($found > 0) {
            $chips[] = sprintf(__('Found in source: %d', 'wp-seo-next'), $found);
        }
        $chips[] = sprintf(__('Applied: %d', 'wp-seo-next'), $applied);
        if ($skipped > 0) {
            $chips[] = sprintf(__('Skipped: %d', 'wp-seo-next'), $skipped);
        }
        if ($notMapped > 0) {
            $chips[] = sprintf(__('Not mapped: %d', 'wp-seo-next'), $notMapped);
        }
        if ($warnings > 0) {
            $chips[] = sprintf(__('Warnings: %d', 'wp-seo-next'), $warnings);
        }
        if ($manual > 0) {
            $chips[] = sprintf(__('Manual: %d', 'wp-seo-next'), $manual);
        }
        if (!empty($summary['posts_updated'])) {
            $chips[] = sprintf(__('Posts updated: %d', 'wp-seo-next'), (int) $summary['posts_updated']);
        }
        if (!empty($summary['terms_updated'])) {
            $chips[] = sprintf(__('Terms updated: %d', 'wp-seo-next'), (int) $summary['terms_updated']);
        }
        echo '<p class="description" style="margin:8px 0 0;">' . esc_html(implode(' · ', $chips)) . '</p>';

        if (!empty($entryDiff['compact']) && is_string($entryDiff['compact'])) {
            echo '<p style="margin:8px 0 0;"><strong>' . esc_html__('Compact diff', 'wp-seo-next') . ':</strong> ' . esc_html((string) $entryDiff['compact']) . '</p>';
        }
        if (!empty($entryDiff['focus']) && is_string($entryDiff['focus'])) {
            echo '<p class="description" style="margin:4px 0 0;">' . esc_html((string) $entryDiff['focus']) . '</p>';
        }
        if ($warningSummary !== '') {
            echo '<p class="description" style="margin:4px 0 0;"><strong>' . esc_html__('Warnings by source', 'wp-seo-next') . ':</strong> ' . esc_html($warningSummary) . '</p>';
        }

        if (!empty($sourceFoundItems)) {
            echo '<div style="margin-top:10px;padding:10px;border:1px solid #bfdbfe;background:#eff6ff;border-radius:4px;">';
            echo '<p style="margin:0 0 6px;"><strong>' . esc_html__('Found in source', 'wp-seo-next') . '</strong></p>';
            echo '<ul style="margin:0 0 0 18px;list-style:disc;">';
            foreach (array_slice($sourceFoundItems, 0, 4) as $item) {
                echo '<li>' . esc_html((string) $item) . '</li>';
            }
            if (count($sourceFoundItems) > 4) {
                echo '<li>' . esc_html__('…', 'wp-seo-next') . '</li>';
            }
            echo '</ul>';
            echo '</div>';
        }

        if (!empty($notMappedItems)) {
            echo '<div style="margin-top:10px;padding:10px;border:1px solid #f59e0b;background:#fffbeb;border-radius:4px;">';
            echo '<p style="margin:0 0 6px;"><strong>' . esc_html__('Not mapped yet', 'wp-seo-next') . '</strong></p>';
            echo '<ul style="margin:0 0 0 18px;list-style:disc;">';
            foreach (array_slice($notMappedItems, 0, 5) as $item) {
                echo '<li>' . esc_html((string) $item) . '</li>';
            }
            if (count($notMappedItems) > 5) {
                echo '<li>' . esc_html__('…', 'wp-seo-next') . '</li>';
            }
            echo '</ul>';
            echo '</div>';
        }

        if (!empty($manualActions)) {
            echo '<div style="margin-top:10px;padding:10px;border:1px solid #f0c36d;background:#fff8e5;border-radius:4px;">';
            echo '<p style="margin:0 0 6px;"><strong>' . esc_html__('Needs manual review', 'wp-seo-next') . '</strong></p>';
            echo '<ul style="margin:0 0 0 18px;list-style:disc;">';
            foreach (array_slice($manualActions, 0, 5) as $item) {
                echo '<li>' . esc_html((string) $item) . '</li>';
            }
            echo '</ul>';
            echo '</div>';
        }

        if (!empty($warningGroups)) {
            echo '<div style="margin-top:10px;padding:10px;border:1px solid #facc15;background:#fffbeb;border-radius:4px;">';
            echo '<p style="margin:0 0 6px;"><strong>' . esc_html__('Warnings by source / plugin', 'wp-seo-next') . '</strong></p>';
            echo '<div style="display:flex;flex-wrap:wrap;gap:6px;">';
            foreach ($warningGroups as $group) {
                if (!is_array($group)) {
                    continue;
                }
                $label = isset($group['label']) ? (string) $group['label'] : __('Warnings', 'wp-seo-next');
                $count = max(0, (int) ($group['count'] ?? 0));
                if ($count <= 0) {
                    continue;
                }
                echo '<span style="display:inline-flex;align-items:center;gap:4px;padding:2px 8px;border:1px solid #f59e0b;border-radius:999px;background:#fff7ed;font-size:12px;line-height:1.8;">' . esc_html(sprintf(__('%1$s · %2$d', 'wp-seo-next'), $label, $count)) . '</span>';
            }
            echo '</div>';
            if ($detailMode !== 'full') {
                echo '<p class="description" style="margin:6px 0 0;">' . esc_html__('Switch this card to Full detail to inspect grouped warning lists.', 'wp-seo-next') . '</p>';
            }
            echo '</div>';
        }

        $checklist = is_array($resolvedChecklist) ? $resolvedChecklist : $this->importAssistChecklistBuilder->build($entry, $reviewUrl);
        $checklist = $this->importAssistChecklistStateRepo->apply($checklist, $entryId);
        $visibleChecklist = is_array($visibleChecklist) ? $visibleChecklist : $this->filterImportAssistChecklist($checklist, $filters);
        if (!empty($checklist) && !$this->importAssistFiltersActive($filters)) {
            $visibleChecklist = $checklist;
        }
        if (!empty($visibleChecklist)) {
            $checkedTasks = $this->importAssistChecklistStateRepo->checkedCount($entryId, $checklist);
            $visibleCheckedTasks = $this->importAssistChecklistStateRepo->checkedCount($entryId, $visibleChecklist);
            $totalTasks = count($checklist);
            $visibleTasks = count($visibleChecklist);
            $allTaskRefs = $this->buildImportAssistTaskRefs($entryId, $visibleChecklist);
            $highTaskRefs = $this->buildImportAssistTaskRefs($entryId, $visibleChecklist, 'high');
            $nextTask = $this->importAssistGuidedReviewPlanner->nextTask($visibleChecklist, $entryId, $title);
            echo '<div style="margin-top:10px;padding:10px;border:1px solid #cbd5e1;background:#f8fafc;border-radius:4px;">';
            echo '<div style="display:flex;justify-content:space-between;gap:10px;align-items:flex-start;margin:0 0 8px;">';
            echo '<div>';
            echo '<p style="margin:0;"><strong>' . esc_html__('Guided checklist', 'wp-seo-next') . '</strong></p>';
            $progressLine = sprintf(__('Checked: %1$d / %2$d', 'wp-seo-next'), $checkedTasks, $totalTasks);
            if ($this->importAssistFiltersActive($filters) && $visibleTasks !== $totalTasks) {
                $progressLine .= ' · ' . sprintf(__('Showing: %1$d matching tasks (%2$d checked)', 'wp-seo-next'), $visibleTasks, $visibleCheckedTasks);
            }
            echo '<p class="description" style="margin:4px 0 0;">' . esc_html($progressLine) . '</p>';
            echo '</div>';
            echo '<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end;">';
            if (is_array($nextTask) && !empty($nextTask['url'])) {
                echo '<a class="button button-small button-primary" href="' . esc_url((string) $nextTask['url']) . '" title="' . esc_attr__('Open the next unchecked checklist item for this import card.', 'wp-seo-next') . '">' . esc_html__('Open next unchecked', 'wp-seo-next') . '</a>';
            }
            if (!empty($allTaskRefs)) {
                $this->renderImportAssistBulkForm($allTaskRefs, 'check', __('Mark all checked', 'wp-seo-next'));
                if (!empty($highTaskRefs)) {
                    $this->renderImportAssistBulkForm($highTaskRefs, 'check', __('Check high priority', 'wp-seo-next'));
                }
                $this->renderImportAssistBulkForm($allTaskRefs, 'reopen', __('Reset checklist', 'wp-seo-next'));
            }
            echo '</div>';
            echo '</div>';
            echo '<div style="display:grid;gap:8px;">';
            foreach ($visibleChecklist as $task) {
                if (!is_array($task)) {
                    continue;
                }
                $taskLabel = isset($task['label']) ? (string) $task['label'] : __('Checklist item', 'wp-seo-next');
                $taskDesc = isset($task['description']) ? (string) $task['description'] : '';
                $taskUrl = isset($task['url']) ? (string) $task['url'] : '';
                $taskUrlLabel = isset($task['url_label']) ? (string) $task['url_label'] : __('Open', 'wp-seo-next');
                $priority = isset($task['priority']) ? (string) $task['priority'] : 'medium';
                $taskKey = isset($task['key']) ? sanitize_key((string) $task['key']) : '';
                $checked = !empty($task['checked']);
                $checkedAt = isset($task['checked_at']) ? (string) $task['checked_at'] : '';
                $priorityLabel = $priority === 'high' ? __('High priority', 'wp-seo-next') : ($priority === 'low' ? __('Nice to review', 'wp-seo-next') : __('Recommended', 'wp-seo-next'));
                $borderColor = $checked ? '#b7e1c1' : '#e2e8f0';
                $background = $checked ? '#f0fdf4' : '#fff';
                echo '<div style="padding:8px 10px;border:1px solid ' . esc_attr($borderColor) . ';background:' . esc_attr($background) . ';border-radius:4px;display:flex;justify-content:space-between;gap:10px;align-items:flex-start;">';
                echo '<div>';
                echo '<p style="margin:0 0 4px;"><strong>' . esc_html($taskLabel) . '</strong> <span class="description">· ' . esc_html($priorityLabel) . '</span>';
                if ($checked) {
                    echo ' <span style="display:inline-block;padding:1px 6px;border-radius:999px;background:#dcfce7;border:1px solid #86efac;font-size:11px;line-height:1.6;">' . esc_html__('Checked', 'wp-seo-next') . '</span>';
                }
                echo '</p>';
                if ($taskDesc !== '') {
                    echo '<p class="description" style="margin:0;">' . esc_html($taskDesc) . '</p>';
                }
                if ($checked && $checkedAt !== '') {
                    echo '<p class="description" style="margin:4px 0 0;">' . esc_html(sprintf(__('Marked checked on %s', 'wp-seo-next'), $checkedAt)) . '</p>';
                }
                echo '</div>';
                echo '<div style="display:flex;flex-wrap:wrap;gap:6px;justify-content:flex-end;">';
                if ($taskUrl !== '') {
                    echo '<a class="button button-small" href="' . esc_url($taskUrl) . '">' . esc_html($taskUrlLabel) . '</a>';
                    $this->renderImportAssistTaskForm($entryId, $taskKey, 'check_and_open', $taskUrl, __('Open + mark checked', 'wp-seo-next'));
                }
                if ($taskKey !== '') {
                    if ($checked) {
                        $this->renderImportAssistTaskForm($entryId, $taskKey, 'reopen', '', __('Mark for review again', 'wp-seo-next'));
                    } else {
                        $this->renderImportAssistTaskForm($entryId, $taskKey, 'check', '', __('Mark checked', 'wp-seo-next'));
                    }
                }
                echo '</div>';
                echo '</div>';
            }
            echo '</div>';
            echo '</div>';
        }

        echo '<details style="margin-top:10px;"' . ($detailMode === 'full' ? ' open' : '') . '>';
        echo '<summary>' . esc_html($detailMode === 'full' ? __('Full detail', 'wp-seo-next') : __('Show full detail', 'wp-seo-next')) . '</summary>';
        $this->renderImportAssistList(__('Found in source', 'wp-seo-next'), $sourceFoundItems, 6);
        $this->renderImportAssistList(__('Applied', 'wp-seo-next'), isset($items['applied']) && is_array($items['applied']) ? $items['applied'] : [], 6);
        $this->renderImportAssistList(__('Skipped / source gaps', 'wp-seo-next'), isset($items['skipped']) && is_array($items['skipped']) ? $items['skipped'] : [], 6);
        $this->renderImportAssistList(__('Not mapped yet', 'wp-seo-next'), $notMappedItems, 6);
        if (!empty($warningGroups)) {
            $this->renderImportAssistWarningGroups(__('Warnings', 'wp-seo-next'), $warningGroups, 4);
        } else {
            $this->renderImportAssistList(__('Warnings', 'wp-seo-next'), $warningItems, 6);
        }
        $this->renderImportAssistList(__('Manual follow-up', 'wp-seo-next'), $manualActions, 6);
        echo '</details>';

        echo '</div>';
    }


    /**
     * @param array<int,array<string,mixed>> $tasks
     * @return array<int,string>
     */
    private function buildImportAssistTaskRefs(string $entryId, array $tasks, string $priorityFilter = ''): array
    {
        $refs = [];
        $entryId = strtolower(trim($entryId));
        if ($entryId === '') {
            return [];
        }

        foreach ($tasks as $task) {
            if (!is_array($task)) {
                continue;
            }
            $taskKey = isset($task['key']) ? sanitize_key((string) $task['key']) : '';
            if ($taskKey === '') {
                continue;
            }
            if ($priorityFilter !== '') {
                $priority = isset($task['priority']) ? sanitize_key((string) $task['priority']) : 'medium';
                if ($priority !== sanitize_key($priorityFilter)) {
                    continue;
                }
            }
            $refs[] = $entryId . ':' . $taskKey;
        }

        return array_values(array_unique($refs));
    }

    /**
     * @param array<int,mixed> $taskRefs
     * @return array<string,array<int,string>>
     */
    private function groupImportAssistTaskRefs(array $taskRefs): array
    {
        $grouped = [];
        foreach ($taskRefs as $ref) {
            if (!is_scalar($ref)) {
                continue;
            }
            $ref = trim((string) $ref);
            if ($ref === '' || strpos($ref, ':') === false) {
                continue;
            }
            [$entryId, $taskKey] = array_pad(explode(':', $ref, 2), 2, '');
            $entryId = strtolower(trim((string) $entryId));
            $taskKey = sanitize_key((string) $taskKey);
            if ($entryId === '' || $taskKey === '') {
                continue;
            }
            $grouped[$entryId][$taskKey] = $taskKey;
        }

        foreach ($grouped as $entryId => $keys) {
            $grouped[$entryId] = array_values($keys);
        }

        return $grouped;
    }

    /**
     * @param array<int,string> $taskRefs
     */
    private function renderImportAssistBulkForm(array $taskRefs, string $mode, string $label, string $title = ''): void
    {
        if (empty($taskRefs)) {
            return;
        }

        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;margin:0;">';
        echo '<input type="hidden" name="action" value="wpsn_import_assist_bulk_state" />';
        echo '<input type="hidden" name="mode" value="' . esc_attr($mode) . '" />';
        echo '<input type="hidden" name="redirect_to" value="' . esc_attr($this->currentImportAssistPageUrl()) . '" />';
        foreach ($taskRefs as $taskRef) {
            echo '<input type="hidden" name="task_refs[]" value="' . esc_attr($taskRef) . '" />';
        }
        wp_nonce_field('wpsn_import_assist_bulk_state');
        echo '<button type="submit" class="button button-small"';
        if ($title !== '') {
            echo ' title="' . esc_attr($title) . '"';
        }
        echo '>' . esc_html($label) . '</button>';
        echo '</form>';
    }

    private function renderImportAssistTaskForm(string $entryId, string $taskKey, string $mode, string $taskUrl, string $label): void
    {
        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;margin:0;">';
        echo '<input type="hidden" name="action" value="wpsn_import_assist_task_state" />';
        echo '<input type="hidden" name="entry_id" value="' . esc_attr($entryId) . '" />';
        echo '<input type="hidden" name="task_key" value="' . esc_attr($taskKey) . '" />';
        echo '<input type="hidden" name="mode" value="' . esc_attr($mode) . '" />';
        echo '<input type="hidden" name="redirect_to" value="' . esc_attr($this->currentImportAssistPageUrl()) . '" />';
        if ($taskUrl !== '') {
            echo '<input type="hidden" name="task_url" value="' . esc_attr($taskUrl) . '" />';
        }
        wp_nonce_field('wpsn_import_assist_task_state');
        echo '<button type="submit" class="button button-small">' . esc_html($label) . '</button>';
        echo '</form>';
    }


    /**
     * @return array{status:string,priority:string,focus:string,preset:string,preset_label:string,default_preset:string,explicit:bool}
     */
    private function getImportAssistFilters(): array
    {
        $status = isset($_GET['assist_task_status']) ? sanitize_key((string) wp_unslash($_GET['assist_task_status'])) : 'all';
        if (!in_array($status, ['all', 'open', 'checked'], true)) {
            $status = 'all';
        }

        $priority = isset($_GET['assist_task_priority']) ? sanitize_key((string) wp_unslash($_GET['assist_task_priority'])) : 'all';
        if (!in_array($priority, ['all', 'high', 'medium', 'low'], true)) {
            $priority = 'all';
        }

        $focus = isset($_GET['assist_task_focus']) ? sanitize_key((string) wp_unslash($_GET['assist_task_focus'])) : 'all';
        if (!in_array($focus, ['all', 'technical', 'schema_content', 'redirects_links', 'presentation', 'reports'], true)) {
            $focus = 'all';
        }

        $detail = isset($_GET['assist_detail']) ? sanitize_key((string) wp_unslash($_GET['assist_detail'])) : 'compact';
        if (!in_array($detail, ['compact', 'full'], true)) {
            $detail = 'compact';
        }

        $explicit = isset($_GET['assist_task_status']) || isset($_GET['assist_task_priority']) || isset($_GET['assist_task_focus']);
        $defaultPreset = $this->importAssistFilterPresetRepo->defaultSlug();
        $presetSlug = isset($_GET['assist_preset']) ? sanitize_key((string) wp_unslash($_GET['assist_preset'])) : '';
        $presetLabel = '';

        if (!$explicit) {
            if ($presetSlug === '' && $defaultPreset !== '') {
                $presetSlug = $defaultPreset;
            }

            if ($presetSlug !== '') {
                $preset = $this->resolveImportAssistPreset($presetSlug);
                if ($preset !== null) {
                    $status = (string) ($preset['status'] ?? 'all');
                    $priority = (string) ($preset['priority'] ?? 'all');
                    $focus = (string) ($preset['focus'] ?? 'all');
                    $presetSlug = (string) ($preset['slug'] ?? '');
                    $presetLabel = (string) ($preset['label'] ?? '');
                } else {
                    $presetSlug = '';
                }
            }
        }

        return [
            'status' => $status,
            'priority' => $priority,
            'focus' => $focus,
            'detail' => $detail,
            'preset' => $presetSlug,
            'preset_label' => $presetLabel,
            'default_preset' => $defaultPreset,
            'explicit' => $explicit,
        ];
    }

    /**
     * @return array<string,array{slug:string,label:string,status:string,priority:string,focus:string,builtin:bool}>
     */
    private function importAssistAvailablePresets(): array
    {
        $presets = [];
        foreach ($this->importAssistBuiltInPresets() as $slug => $preset) {
            $presets[$slug] = $preset;
        }

        foreach ($this->importAssistFilterPresetRepo->all() as $slug => $preset) {
            $presets[$slug] = [
                'slug' => (string) ($preset['slug'] ?? $slug),
                'label' => (string) ($preset['label'] ?? $slug),
                'status' => (string) ($preset['status'] ?? 'all'),
                'priority' => (string) ($preset['priority'] ?? 'all'),
                'focus' => (string) ($preset['focus'] ?? 'all'),
                'builtin' => false,
            ];
        }

        return $presets;
    }

    /**
     * @return array<string,array{slug:string,label:string,status:string,priority:string,focus:string,builtin:bool}>
     */
    private function importAssistBuiltInPresets(): array
    {
        return [
            'all' => [
                'slug' => 'all',
                'label' => __('All tasks', 'wp-seo-next'),
                'status' => 'all',
                'priority' => 'all',
                'focus' => 'all',
                'builtin' => true,
            ],
            'open' => [
                'slug' => 'open',
                'label' => __('Open tasks', 'wp-seo-next'),
                'status' => 'open',
                'priority' => 'all',
                'focus' => 'all',
                'builtin' => true,
            ],
            'open-high' => [
                'slug' => 'open-high',
                'label' => __('Open high priority', 'wp-seo-next'),
                'status' => 'open',
                'priority' => 'high',
                'focus' => 'all',
                'builtin' => true,
            ],
            'open-medium' => [
                'slug' => 'open-medium',
                'label' => __('Open recommended', 'wp-seo-next'),
                'status' => 'open',
                'priority' => 'medium',
                'focus' => 'all',
                'builtin' => true,
            ],
            'checked' => [
                'slug' => 'checked',
                'label' => __('Checked tasks', 'wp-seo-next'),
                'status' => 'checked',
                'priority' => 'all',
                'focus' => 'all',
                'builtin' => true,
            ],
            'tech-open' => [
                'slug' => 'tech-open',
                'label' => __('Technical fixes', 'wp-seo-next'),
                'status' => 'open',
                'priority' => 'all',
                'focus' => 'technical',
                'builtin' => true,
            ],
            'schema-open' => [
                'slug' => 'schema-open',
                'label' => __('Schema and content', 'wp-seo-next'),
                'status' => 'open',
                'priority' => 'all',
                'focus' => 'schema_content',
                'builtin' => true,
            ],
            'redirects-open' => [
                'slug' => 'redirects-open',
                'label' => __('Redirects and links', 'wp-seo-next'),
                'status' => 'open',
                'priority' => 'all',
                'focus' => 'redirects_links',
                'builtin' => true,
            ],
            'presentation-open' => [
                'slug' => 'presentation-open',
                'label' => __('Presentation and local', 'wp-seo-next'),
                'status' => 'open',
                'priority' => 'all',
                'focus' => 'presentation',
                'builtin' => true,
            ],
            'reports-open' => [
                'slug' => 'reports-open',
                'label' => __('Reports and QA', 'wp-seo-next'),
                'status' => 'open',
                'priority' => 'all',
                'focus' => 'reports',
                'builtin' => true,
            ],
        ];
    }

    /**
     * @return array{slug:string,label:string,status:string,priority:string,focus:string,builtin:bool}|null
     */
    private function resolveImportAssistPreset(string $slug): ?array
    {
        $slug = sanitize_key($slug);
        if ($slug === '') {
            return null;
        }

        $presets = $this->importAssistAvailablePresets();
        return $presets[$slug] ?? null;
    }

    /**
     * @param array<string,mixed> $filters
     */
    private function importAssistFiltersActive(array $filters): bool
    {
        return (($filters['status'] ?? 'all') !== 'all')
            || (($filters['priority'] ?? 'all') !== 'all')
            || (($filters['focus'] ?? 'all') !== 'all');
    }

    /**
     * @param array<string,mixed> $filters
     * @return array<string,string>
     */
    private function importAssistFilterQueryArgs(array $filters = []): array
    {
        $filters = array_merge($this->getImportAssistFilters(), $filters);
        $args = [];
        $preset = isset($filters['preset']) ? sanitize_key((string) $filters['preset']) : '';
        $explicit = !empty($filters['explicit']);

        if ($preset !== '' && !$explicit) {
            $args['assist_preset'] = $preset;
            return $args;
        }

        if (($filters['status'] ?? 'all') !== 'all') {
            $args['assist_task_status'] = (string) $filters['status'];
        }
        if (($filters['priority'] ?? 'all') !== 'all') {
            $args['assist_task_priority'] = (string) $filters['priority'];
        }
        if (($filters['focus'] ?? 'all') !== 'all') {
            $args['assist_task_focus'] = (string) $filters['focus'];
        }
        if (($filters['detail'] ?? 'compact') === 'full') {
            $args['assist_detail'] = 'full';
        }

        return $args;
    }

    private function currentImportAssistPageUrl(array $args = []): string
    {
        return $this->pageUrl(array_merge($this->importAssistFilterQueryArgs(), $args));
    }

    /**
     * @param array<int,array<string,mixed>> $checklist
     * @param array<string,mixed> $filters
     * @return array<int,array<string,mixed>>
     */
    private function filterImportAssistChecklist(array $checklist, array $filters): array
    {
        $status = isset($filters['status']) ? sanitize_key((string) $filters['status']) : 'all';
        $priority = isset($filters['priority']) ? sanitize_key((string) $filters['priority']) : 'all';
        $focus = isset($filters['focus']) ? sanitize_key((string) $filters['focus']) : 'all';

        if ($status === 'all' && $priority === 'all' && $focus === 'all') {
            return $checklist;
        }

        $filtered = [];
        foreach ($checklist as $task) {
            if (!is_array($task)) {
                continue;
            }

            $checked = !empty($task['checked']);
            if ($status === 'open' && $checked) {
                continue;
            }
            if ($status === 'checked' && !$checked) {
                continue;
            }

            $taskPriority = isset($task['priority']) ? sanitize_key((string) $task['priority']) : 'medium';
            if ($priority !== 'all' && $taskPriority !== $priority) {
                continue;
            }

            if (!$this->taskMatchesImportAssistFocus($task, $focus)) {
                continue;
            }

            $filtered[] = $task;
        }

        return $filtered;
    }

    /**
     * @return array<string,string>
     */
    private function importAssistFocusLabelMap(): array
    {
        return [
            'all' => __('All guided views', 'wp-seo-next'),
            'technical' => __('Technical fixes', 'wp-seo-next'),
            'schema_content' => __('Schema and content', 'wp-seo-next'),
            'redirects_links' => __('Redirects and links', 'wp-seo-next'),
            'presentation' => __('Presentation and local', 'wp-seo-next'),
            'reports' => __('Reports and QA', 'wp-seo-next'),
        ];
    }

    /**
     * @param array<string,mixed> $task
     */
    private function taskMatchesImportAssistFocus(array $task, string $focus): bool
    {
        $focus = sanitize_key($focus);
        if ($focus === '' || $focus === 'all') {
            return true;
        }

        $taskKey = isset($task['key']) ? sanitize_key((string) $task['key']) : '';
        if ($taskKey === '') {
            return false;
        }

        $groups = [
            'technical' => ['archives', 'robots', 'search_urls', 'sitemaps', 'indexnow'],
            'schema_content' => ['schema', 'templates', 'content_qa'],
            'redirects_links' => ['redirects'],
            'presentation' => ['breadcrumbs', 'social', 'local'],
            'reports' => ['report', 'content_qa'],
        ];

        return isset($groups[$focus]) && in_array($taskKey, $groups[$focus], true);
    }

    /**
     * @param array<string,mixed> $filters
     */
    private function renderImportAssistFilterControls(array $filters, int $visibleEntries, int $visibleTasks, int $allTasks): void
    {
        $status = isset($filters['status']) ? sanitize_key((string) $filters['status']) : 'all';
        $priority = isset($filters['priority']) ? sanitize_key((string) $filters['priority']) : 'all';
        $focus = isset($filters['focus']) ? sanitize_key((string) $filters['focus']) : 'all';
        $detail = $this->importAssistDetailMode($filters);
        $activePreset = isset($filters['preset']) ? sanitize_key((string) $filters['preset']) : '';
        $activePresetLabel = isset($filters['preset_label']) ? (string) $filters['preset_label'] : '';
        $defaultPreset = $this->importAssistFilterPresetRepo->defaultSlug();
        $statusLabelMap = [
            'all' => __('All checklist statuses', 'wp-seo-next'),
            'open' => __('Open only', 'wp-seo-next'),
            'checked' => __('Checked only', 'wp-seo-next'),
        ];
        $priorityLabelMap = [
            'all' => __('All priorities', 'wp-seo-next'),
            'high' => __('High priority', 'wp-seo-next'),
            'medium' => __('Recommended', 'wp-seo-next'),
            'low' => __('Nice to review', 'wp-seo-next'),
        ];
        $focusLabelMap = $this->importAssistFocusLabelMap();
        $presets = $this->importAssistAvailablePresets();

        echo '<div style="margin:12px 0 10px;padding:10px 12px;border:1px solid #dcdcde;background:#fff;border-radius:4px;max-width:1200px;">';
        echo '<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin:0 0 10px;">';
        echo '<strong>' . esc_html__('Presets', 'wp-seo-next') . '</strong>';
        foreach ($presets as $slug => $preset) {
            $label = (string) ($preset['label'] ?? $slug);
            if ($slug === $defaultPreset) {
                $label .= ' ★';
            }
            $class = ($activePreset !== '' && $activePreset === $slug) ? 'button button-primary button-small' : 'button button-secondary button-small';
            echo '<span style="display:inline-flex;gap:4px;align-items:center;">';
            echo '<a class="' . esc_attr($class) . '" href="' . esc_url($this->pageUrl(array_merge(['assist_preset' => $slug], $detail === 'full' ? ['assist_detail' => 'full'] : []))) . '">' . esc_html($label) . '</a>';
            if (empty($preset['builtin'])) {
                $this->renderImportAssistFilterPresetForm('delete', __('Delete', 'wp-seo-next'), $slug, '', ['class' => 'button button-link-delete button-small']);
            }
            echo '</span>';
        }
        echo '</div>';

        echo '<form method="get" action="' . esc_url(admin_url('tools.php')) . '" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;">';
        echo '<input type="hidden" name="page" value="' . esc_attr(self::PAGE_SLUG) . '" />';
        echo '<input type="hidden" name="assist_detail" value="' . esc_attr($detail) . '" />';
        echo '<label><span style="display:block;margin:0 0 4px;"><strong>' . esc_html__('Checklist status', 'wp-seo-next') . '</strong></span>';
        echo '<select name="assist_task_status">';
        foreach ($statusLabelMap as $value => $label) {
            echo '<option value="' . esc_attr($value) . '"' . selected($status, $value, false) . '>' . esc_html($label) . '</option>';
        }
        echo '</select></label>';
        echo '<label><span style="display:block;margin:0 0 4px;"><strong>' . esc_html__('Priority', 'wp-seo-next') . '</strong></span>';
        echo '<select name="assist_task_priority">';
        foreach ($priorityLabelMap as $value => $label) {
            echo '<option value="' . esc_attr($value) . '"' . selected($priority, $value, false) . '>' . esc_html($label) . '</option>';
        }
        echo '</select></label>';
        echo '<label><span style="display:block;margin:0 0 4px;"><strong>' . esc_html__('Guided view', 'wp-seo-next') . '</strong></span>';
        echo '<select name="assist_task_focus">';
        foreach ($focusLabelMap as $value => $label) {
            echo '<option value="' . esc_attr($value) . '"' . selected($focus, $value, false) . '>' . esc_html($label) . '</option>';
        }
        echo '</select></label>';
        echo '<button type="submit" class="button button-secondary">' . esc_html__('Apply filters', 'wp-seo-next') . '</button>';
        echo '<a class="button button-link" href="' . esc_url($this->pageUrl(array_merge(['assist_preset' => 'all'], $detail === 'full' ? ['assist_detail' => 'full'] : []))) . '">' . esc_html__('Reset to all', 'wp-seo-next') . '</a>';
        echo '</form>';

        echo '<div style="display:flex;flex-wrap:wrap;gap:6px;align-items:center;margin:10px 0 0;">';
        echo '<strong>' . esc_html__('Card detail level', 'wp-seo-next') . '</strong>';
        $compactClass = $detail === 'compact' ? 'button button-primary button-small' : 'button button-secondary button-small';
        $fullClass = $detail === 'full' ? 'button button-primary button-small' : 'button button-secondary button-small';
        echo '<a class="' . esc_attr($compactClass) . '" href="' . esc_url($this->currentImportAssistPageUrl(['assist_detail' => 'compact'])) . '">' . esc_html__('Compact cards', 'wp-seo-next') . '</a>';
        echo '<a class="' . esc_attr($fullClass) . '" href="' . esc_url($this->currentImportAssistPageUrl(['assist_detail' => 'full'])) . '">' . esc_html__('Full detail', 'wp-seo-next') . '</a>';
        echo '<span class="description">' . esc_html__('Compact keeps cards short and grouped. Full detail opens every card list by default.', 'wp-seo-next') . '</span>';
        echo '</div>';

        echo '<div style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;margin:10px 0 0;">';
        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:flex;flex-wrap:wrap;gap:8px;align-items:end;">';
        echo '<input type="hidden" name="action" value="wpsn_import_assist_filter_preset" />';
        echo '<input type="hidden" name="mode" value="save" />';
        echo '<input type="hidden" name="status" value="' . esc_attr($status) . '" />';
        echo '<input type="hidden" name="priority" value="' . esc_attr($priority) . '" />';
        echo '<input type="hidden" name="focus" value="' . esc_attr($focus) . '" />';
        echo '<input type="hidden" name="redirect_to" value="' . esc_attr($this->currentImportAssistPageUrl()) . '" />';
        wp_nonce_field('wpsn_import_assist_filter_preset');
        echo '<label><span style="display:block;margin:0 0 4px;"><strong>' . esc_html__('Save current view as preset', 'wp-seo-next') . '</strong></span>';
        echo '<input type="text" name="preset_label" value="" placeholder="' . esc_attr__('My preset', 'wp-seo-next') . '" /></label>';
        echo '<button type="submit" class="button button-secondary">' . esc_html__('Save preset', 'wp-seo-next') . '</button>';
        echo '</form>';

        if ($activePreset !== '') {
            if ($activePreset === $defaultPreset) {
                echo '<span class="description" style="align-self:center;">' . esc_html__('This preset is the default view.', 'wp-seo-next') . '</span>';
                $this->renderImportAssistFilterPresetForm('clear_default', __('Clear default', 'wp-seo-next'), '', $this->currentImportAssistPageUrl());
            } else {
                $this->renderImportAssistFilterPresetForm('set_default', __('Set current preset as default', 'wp-seo-next'), $activePreset, $this->currentImportAssistPageUrl());
            }
        } elseif ($defaultPreset !== '') {
            $this->renderImportAssistFilterPresetForm('clear_default', __('Clear default', 'wp-seo-next'), '', $this->currentImportAssistPageUrl());
        }
        echo '</div>';

        $summary = sprintf(
            __('Showing %1$d cards and %2$d visible checklist tasks', 'wp-seo-next'),
            max(0, $visibleEntries),
            max(0, $visibleTasks)
        );
        if ($allTasks > 0 && $visibleTasks !== $allTasks) {
            $summary .= ' · ' . sprintf(__('out of %d total tasks', 'wp-seo-next'), max(0, $allTasks));
        }
        if ($activePreset !== '' && $activePresetLabel !== '') {
            $summary .= ' · ' . sprintf(__('Preset: %s', 'wp-seo-next'), $activePresetLabel);
        } elseif ($this->importAssistFiltersActive($filters)) {
            $parts = [
                $statusLabelMap[$status] ?? $statusLabelMap['all'],
                $priorityLabelMap[$priority] ?? $priorityLabelMap['all'],
            ];
            if ($focus !== 'all') {
                $parts[] = $focusLabelMap[$focus] ?? $focusLabelMap['all'];
            }
            $summary .= ' · ' . sprintf(__('Filter: %s', 'wp-seo-next'), implode(' / ', array_filter($parts)));
        }
        $summary .= ' · ' . sprintf(__('Cards: %s', 'wp-seo-next'), $detail === 'full' ? __('Full detail', 'wp-seo-next') : __('Compact', 'wp-seo-next'));
        echo '<p class="description" style="margin:8px 0 0;">' . esc_html($summary) . '</p>';
        echo '</div>';
    }

    /**
     * @param array<string,mixed> $filters
     */
    private function importAssistDetailMode(array $filters): string
    {
        $detail = isset($filters['detail']) ? sanitize_key((string) $filters['detail']) : 'compact';
        return $detail === 'full' ? 'full' : 'compact';
    }

    private function renderImportAssistFilterPresetForm(string $mode, string $label, string $presetSlug = '', string $redirectTo = '', array $buttonArgs = []): void
    {
        $buttonClass = isset($buttonArgs['class']) ? (string) $buttonArgs['class'] : 'button button-secondary';
        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;margin:0;">';
        echo '<input type="hidden" name="action" value="wpsn_import_assist_filter_preset" />';
        echo '<input type="hidden" name="mode" value="' . esc_attr($mode) . '" />';
        if ($presetSlug !== '') {
            echo '<input type="hidden" name="preset_slug" value="' . esc_attr($presetSlug) . '" />';
        }
        echo '<input type="hidden" name="redirect_to" value="' . esc_attr($redirectTo !== '' ? $redirectTo : $this->currentImportAssistPageUrl()) . '" />';
        wp_nonce_field('wpsn_import_assist_filter_preset');
        echo '<button type="submit" class="' . esc_attr($buttonClass) . '">' . esc_html($label) . '</button>';
        echo '</form>';
    }

    /**
     * @param array<int,string> $items
     */
    private function renderImportAssistList(string $label, array $items, int $limit): void
    {
        if (empty($items)) {
            return;
        }

        echo '<p style="margin:10px 0 4px;"><strong>' . esc_html($label) . '</strong></p>';
        echo '<ul style="margin:0 0 0 18px;list-style:disc;">';
        foreach (array_slice($items, 0, max(1, $limit)) as $item) {
            echo '<li>' . esc_html((string) $item) . '</li>';
        }
        if (count($items) > $limit) {
            echo '<li>' . esc_html__('…', 'wp-seo-next') . '</li>';
        }
        echo '</ul>';
    }

    /**
     * @param array<int,array{key:string,label:string,count:int,items:array<int,string>}> $groups
     */
    private function renderImportAssistWarningGroups(string $label, array $groups, int $limit): void
    {
        if (empty($groups)) {
            return;
        }

        echo '<p style="margin:10px 0 4px;"><strong>' . esc_html($label) . '</strong></p>';
        foreach ($groups as $group) {
            if (!is_array($group)) {
                continue;
            }
            $groupLabel = isset($group['label']) ? (string) $group['label'] : __('Warnings', 'wp-seo-next');
            $groupItems = isset($group['items']) && is_array($group['items']) ? $group['items'] : [];
            if (empty($groupItems)) {
                continue;
            }

            echo '<p class="description" style="margin:8px 0 4px;"><strong>' . esc_html($groupLabel) . '</strong> · ' . esc_html(sprintf(_n('%d warning', '%d warnings', count($groupItems), 'wp-seo-next'), count($groupItems))) . '</p>';
            echo '<ul style="margin:0 0 0 18px;list-style:disc;">';
            foreach (array_slice($groupItems, 0, max(1, $limit)) as $item) {
                echo '<li>' . esc_html((string) $item) . '</li>';
            }
            if (count($groupItems) > $limit) {
                echo '<li>' . esc_html__('…', 'wp-seo-next') . '</li>';
            }
            echo '</ul>';
        }
    }

    private function importAssistExportUrl(string $entryId): string
    {
        $entryId = sanitize_key($entryId);
        if ($entryId === '') {
            return '';
        }

        $url = add_query_arg([
            'action' => 'wpsn_export_import_assist',
            'assist_id' => $entryId,
        ], admin_url('admin-post.php'));

        return function_exists('wp_nonce_url')
            ? wp_nonce_url($url, 'wpsn_export_import_assist_' . $entryId)
            : $url;
    }

    private function importAssistExportCsvUrl(string $entryId): string
    {
        $entryId = sanitize_key($entryId);
        if ($entryId === '') {
            return '';
        }

        $url = add_query_arg([
            'action' => 'wpsn_export_import_assist_csv',
            'assist_id' => $entryId,
        ], admin_url('admin-post.php'));

        return function_exists('wp_nonce_url')
            ? wp_nonce_url($url, 'wpsn_export_import_assist_csv_' . $entryId)
            : $url;
    }

    /**
     * @param array<string,mixed> $entry
     * @return array<string,mixed>
     */
    private function buildImportAssistExportPayload(array $entry): array
    {
        $entryId = $this->importAssistRepo->identify($entry);
        $reviewUrl = $this->importAssistReviewUrl($entry);
        $checklist = $this->importAssistChecklistBuilder->build($entry, $reviewUrl);
        $checklist = $this->importAssistChecklistStateRepo->apply($checklist, $entryId);

        return [
            'exported_at_utc' => gmdate('c'),
            'assist_id' => $entryId,
            'review_url' => $reviewUrl,
            'diff' => $this->importAssistDiffPresenter->build($entry),
            'entry' => $entry,
            'checklist' => $checklist,
        ];
    }

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

        $assistId = isset($_GET['assist_id']) ? sanitize_key((string) wp_unslash($_GET['assist_id'])) : '';
        if ($assistId === '') {
            wp_safe_redirect($this->pageUrl());
            exit;
        }

        check_admin_referer('wpsn_export_import_assist_' . $assistId);

        $entry = $this->importAssistRepo->find($assistId);
        if (!is_array($entry)) {
            wp_safe_redirect($this->pageUrl());
            exit;
        }

        $payload = $this->buildImportAssistExportPayload($entry);
        $filename = 'wpsn-import-assist-' . preg_replace('/[^a-zA-Z0-9_-]/', '', $assistId) . '.json';

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

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

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

        $assistId = isset($_GET['assist_id']) ? sanitize_key((string) wp_unslash($_GET['assist_id'])) : '';
        if ($assistId === '') {
            wp_safe_redirect($this->pageUrl());
            exit;
        }

        check_admin_referer('wpsn_export_import_assist_csv_' . $assistId);

        $entry = $this->importAssistRepo->find($assistId);
        if (!is_array($entry)) {
            wp_safe_redirect($this->pageUrl());
            exit;
        }

        $payload = $this->buildImportAssistExportPayload($entry);
        $exporter = new ImportAssistCsvExporter();
        $filename = 'wpsn-import-assist-' . preg_replace('/[^a-zA-Z0-9_-]/', '', $assistId) . '.csv';

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

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

        fputcsv($out, $exporter->headers());
        foreach ($exporter->rows($payload) as $row) {
            if (!is_array($row)) {
                continue;
            }

            $flat = [];
            foreach ($exporter->headers() as $col) {
                $flat[] = isset($row[$col]) ? (string) $row[$col] : '';
            }
            fputcsv($out, $flat);
        }

        fclose($out);
        exit;
    }

    /**
     * @param array<string,mixed> $entry
     */
    private function importAssistReviewUrl(array $entry): string
    {
        $kind = isset($entry['kind']) ? sanitize_key((string) $entry['kind']) : '';
        $source = isset($entry['source']) ? sanitize_key((string) $entry['source']) : '';
        $meta = isset($entry['meta']) && is_array($entry['meta']) ? $entry['meta'] : [];

        if ($kind === 'migration' && !empty($meta['job_id'])) {
            return $this->pageUrl(['job' => (string) $meta['job_id']]);
        }

        $reportId = isset($meta['report_id']) ? sanitize_text_field((string) $meta['report_id']) : '';
        if ($reportId === '') {
            return '';
        }

        if ($kind === 'settings_restore') {
            return $this->pageUrl(['settings_report' => $reportId]);
        }

        $keyMap = [
            'yoast' => 'yoast_report',
            'rankmath' => 'rankmath_report',
            'seopress' => 'seopress_report',
            'aioseo' => 'aioseo_report',
        ];
        if (isset($keyMap[$source])) {
            return $this->pageUrl([$keyMap[$source] => $reportId]);
        }

        return '';
    }

    /**
     * @param array<string,mixed> $report
     */
    private function recordImportAssistSettingsRestore(array $report): void
    {
        $this->importAssistRepo->push($this->importAssistBuilder->fromSettingsJsonRestore($report));
    }

    /**
     * @param array<string,mixed> $report
     */
    private function recordImportAssistSettingsImport(string $source, array $report, string $context = 'file'): void
    {
        $this->importAssistRepo->push($this->importAssistBuilder->fromSettingsImport($source, $report, $context));
    }

    private function recordImportAssistMigration(array $job): void
    {
        $this->importAssistRepo->push($this->importAssistBuilder->fromMigrationReport($this->buildMigrationReport($job)));
    }

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

        $jobId = !empty($_GET['job']) ? sanitize_text_field((string) wp_unslash($_GET['job'])) : '';
        $job = $jobId !== '' ? $this->getJob($jobId) : null;

        $autoRun = !empty($_GET['autorun']) && (string) wp_unslash($_GET['autorun']) === '1';

        $sources = $this->getSourceInfo();

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

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

        $settings = $this->container->get('settings');
        $brand = $settings ? Branding::brand($settings) : '';
        if ($brand !== '') {
            // translators: %s is brand name
            echo '<p>' . esc_html(sprintf(__('Use this screen to export/import %s settings and migrate basic SEO fields from other SEO plugins.', 'wp-seo-next'), $brand)) . '</p>';
        } else {
            echo '<p>' . esc_html__('Use this screen to export/import plugin settings and migrate basic SEO fields from other SEO plugins.', 'wp-seo-next') . '</p>';
        }

        $this->renderImportAssistSection();

        // --- Export settings ---
        echo '<h2>' . esc_html__('Export settings', 'wp-seo-next') . '</h2>';
        echo '<p>' . esc_html__('Download a JSON backup of your plugin settings (including templates and module toggles).', 'wp-seo-next') . '</p>';
        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
        echo '<input type="hidden" name="action" value="wpsn_export_settings" />';
        wp_nonce_field('wpsn_export_settings');
        submit_button(__('Download JSON', 'wp-seo-next'), 'secondary', 'submit', false);
        echo '</form>';

        // --- Import settings ---
        echo '<hr />';
        echo '<h2>' . esc_html__('Import settings', 'wp-seo-next') . '</h2>';
        if ($brand !== '') {
            // translators: %s is brand name
            echo '<p>' . esc_html(sprintf(__('Upload a JSON export created by %s to restore settings.', 'wp-seo-next'), $brand)) . '</p>';
        } else {
            echo '<p>' . esc_html__('Upload a JSON export created by this plugin to restore settings.', 'wp-seo-next') . '</p>';
        }
        echo '<form method="post" enctype="multipart/form-data" action="' . esc_url(admin_url('admin-post.php')) . '">';
        echo '<input type="hidden" name="action" value="wpsn_import_settings" />';
        wp_nonce_field('wpsn_import_settings');
        echo '<input type="file" name="wpsn_settings_file" accept="application/json" required />';
        echo '<p class="description">' . esc_html__('Tip: keep a backup before importing.', 'wp-seo-next') . '</p>';
        submit_button(__('Import settings', 'wp-seo-next'), 'secondary', 'submit', false);
        echo '</form>';

        // Settings import report + one-click rollback.
        $settingsReportId = isset($_GET['settings_report']) ? sanitize_text_field((string) wp_unslash($_GET['settings_report'])) : '';
        if ($settingsReportId !== '') {
            $report = get_transient('wpsn_settings_import_' . $settingsReportId);
            if (is_array($report)) {
                $snapshotId = (int) ($report['snapshot_id'] ?? 0);
                $undoRunId  = (string) ($report['undo_run_id'] ?? '');

                echo '<div class="notice notice-info" style="margin-top:12px; padding:10px 12px;">';
                echo '<p style="margin:0 0 6px;"><strong>' . esc_html__('Import report', 'wp-seo-next') . '</strong></p>';
                echo '<p style="margin:0;">' . esc_html__('Settings import completed.', 'wp-seo-next') . '</p>';
                if ($snapshotId > 0) {
                    echo '<p style="margin:6px 0 0;">' . esc_html(sprintf(__('Auto snapshot created: #%d', 'wp-seo-next'), $snapshotId)) . '</p>';
                } else {
                    echo '<p style="margin:6px 0 0;" class="description">' . esc_html__('Auto snapshot was not created (or failed).', 'wp-seo-next') . '</p>';
                }

                if ($undoRunId !== '') {
                    echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-top:10px;">';
                    echo '<input type="hidden" name="action" value="wpsn_undo_run" />';
                    echo '<input type="hidden" name="run_id" value="' . esc_attr($undoRunId) . '" />';
                    echo '<input type="hidden" name="return" value="' . esc_attr($this->pageUrl(['settings_report' => $settingsReportId])) . '" />';
                    wp_nonce_field('wpsn_undo_run_' . $undoRunId, 'wpsn_nonce');
                    echo '<button type="submit" class="button button-secondary" style="border-color:#b32d2e;color:#b32d2e;" onclick="return confirm(' . wp_json_encode(__('Rollback to auto snapshot? This will overwrite current settings.', 'wp-seo-next')) . ');">' . esc_html__('Rollback to auto snapshot', 'wp-seo-next') . '</button>';
                    echo '</form>';
                }

                echo '</div>';
            }
        }

        // --- Quick import from installed plugin (no file) ---
        echo '<hr />';
        echo '<h2>' . esc_html__('Quick import from installed SEO plugin (no file)', 'wp-seo-next') . '</h2>';
        echo '<p>' . esc_html__('If another SEO plugin is installed on this site, you can import its global settings directly from the database (wp_options) without exporting a file.', 'wp-seo-next') . '</p>';
        echo '<p class="description">' . esc_html__('We always create an automatic Config Snapshot before applying changes, so you can roll back if needed.', 'wp-seo-next') . '</p>';

        $yoastSiteDetected = (get_option('wpseo_titles', null) !== null) || (get_option('wpseo', null) !== null);
        $rankSiteDetected = (get_option('rank-math-options-titles', null) !== null) || (get_option('rank-math-options-general', null) !== null);
        $seopressSiteDetected = (get_option('seopress_titles_option_name', null) !== null) || (get_option('seopress_social_option_name', null) !== null);
        $aioseoSiteDetected = (get_option('aioseo_options', null) !== null) || (get_option('aioseo_options_dynamic', null) !== null) || (get_option('aioseop_options', null) !== null);

        // Yoast
        echo '<div style="border:1px solid #ccd0d4;background:#fff;padding:12px;margin:12px 0;">';
        echo '<h3 style="margin-top:0;">' . esc_html__('Yoast SEO (site options)', 'wp-seo-next') . '</h3>';
        if (!$yoastSiteDetected) {
            echo '<p class="description">' . esc_html__('Not detected on this site (wpseo_titles option missing).', 'wp-seo-next') . '</p>';
        } else {
            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
            echo '<input type="hidden" name="action" value="wpsn_import_yoast_settings_site" />';
            wp_nonce_field('wpsn_import_yoast_settings_site');

            echo '<p style="margin:10px 0 6px;"><strong>' . esc_html__('Import options', 'wp-seo-next') . '</strong></p>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_templates" value="1" checked /> ' . esc_html__('Templates (title/description/author)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_separator" value="1" checked /> ' . esc_html__('Separator', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_schema" value="1" checked /> ' . esc_html__('Organization name/logo/social profiles (Schema defaults)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_social" value="1" checked /> ' . esc_html__('Social default image', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_robots" value="1" checked /> ' . esc_html__('Robots defaults (archives / terms / paged) (best-effort)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_local" value="1" checked /> ' . esc_html__('Local SEO business fields (name / phone / email / address) (best-effort)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_indexnow" value="1" checked /> ' . esc_html__('IndexNow key (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_verification" value="1" checked /> ' . esc_html__('Site verification codes (Google/Bing/Yandex/Baidu/Ahrefs) (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_breadcrumbs" value="1" checked /> ' . esc_html__('Breadcrumbs (enabled + labels + separator) (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_archive_controls" value="1" checked /> ' . esc_html__('Archive controls (author/date/attachment pages) (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_cleanup" value="1" checked /> ' . esc_html__('Head cleanup / hardening toggles (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_search_urls" value="1" checked /> ' . esc_html__('Search URL canonicalization / search crawl cleanup (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_ai_readiness" value="1" /> ' . esc_html__('AI Search Readiness (llms.txt + AI crawler policies) (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:8px 0 2px 0;">';
            echo '<input type="checkbox" name="overwrite" value="1" /> ' . esc_html__('Overwrite existing WP SEO Next values (recommended OFF).', 'wp-seo-next');
            echo '</label>';

            submit_button(__('Import from Yoast (site)', 'wp-seo-next'), 'secondary', 'submit', false);
            echo '</form>';
        }
        echo '</div>';

        // Rank Math
        echo '<div style="border:1px solid #ccd0d4;background:#fff;padding:12px;margin:12px 0;">';
        echo '<h3 style="margin-top:0;">' . esc_html__('Rank Math (site options)', 'wp-seo-next') . '</h3>';
        if (!$rankSiteDetected) {
            echo '<p class="description">' . esc_html__('Not detected on this site (rank-math options missing).', 'wp-seo-next') . '</p>';
        } else {
            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
            echo '<input type="hidden" name="action" value="wpsn_import_rankmath_settings_site" />';
            wp_nonce_field('wpsn_import_rankmath_settings_site');

            echo '<p style="margin:10px 0 6px;"><strong>' . esc_html__('Import options', 'wp-seo-next') . '</strong></p>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_templates" value="1" checked /> ' . esc_html__('Templates (title/description/author)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_separator" value="1" checked /> ' . esc_html__('Separator', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_schema" value="1" checked /> ' . esc_html__('Organization name/logo (Schema defaults) (best-effort)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_social" value="1" checked /> ' . esc_html__('Social default image (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_breadcrumbs" value="1" checked /> ' . esc_html__('Breadcrumbs (enabled + home label + separator) (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:8px 0 2px 0;">';
            echo '<input type="checkbox" name="overwrite" value="1" /> ' . esc_html__('Overwrite existing WP SEO Next values (recommended OFF).', 'wp-seo-next');
            echo '</label>';

            submit_button(__('Import from Rank Math (site)', 'wp-seo-next'), 'secondary', 'submit', false);
            echo '</form>';
        }
        echo '</div>';

        // SEOPress
        echo '<div style="border:1px solid #ccd0d4;background:#fff;padding:12px;margin:12px 0;">';
        echo '<h3 style="margin-top:0;">' . esc_html__('SEOPress (site options)', 'wp-seo-next') . '</h3>';
        if (!$seopressSiteDetected) {
            echo '<p class="description">' . esc_html__('Not detected on this site (seopress options missing).', 'wp-seo-next') . '</p>';
        } else {
            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
            echo '<input type="hidden" name="action" value="wpsn_import_seopress_settings_site" />';
            wp_nonce_field('wpsn_import_seopress_settings_site');

            echo '<p style="margin:10px 0 6px;"><strong>' . esc_html__('Import options', 'wp-seo-next') . '</strong></p>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_templates" value="1" checked /> ' . esc_html__('Templates (title/description/author)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_separator" value="1" checked /> ' . esc_html__('Separator', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_schema" value="1" checked /> ' . esc_html__('Organization name/logo (Schema defaults) (best-effort)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_social" value="1" checked /> ' . esc_html__('Social default image (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_breadcrumbs" value="1" checked /> ' . esc_html__('Breadcrumbs (enabled + home label + separator) (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:8px 0 2px 0;">';
            echo '<input type="checkbox" name="overwrite" value="1" /> ' . esc_html__('Overwrite existing WP SEO Next values (recommended OFF).', 'wp-seo-next');
            echo '</label>';

            submit_button(__('Import from SEOPress (site)', 'wp-seo-next'), 'secondary', 'submit', false);
            echo '</form>';
        }
        echo '</div>';

        // AIOSEO
        echo '<div style="border:1px solid #ccd0d4;background:#fff;padding:12px;margin:12px 0;">';
        echo '<h3 style="margin-top:0;">' . esc_html__('AIOSEO / All in One SEO (site options)', 'wp-seo-next') . '</h3>';
        if (!$aioseoSiteDetected) {
            echo '<p class="description">' . esc_html__('Not detected on this site (aioseo/aioseop options missing).', 'wp-seo-next') . '</p>';
        } else {
            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
            echo '<input type="hidden" name="action" value="wpsn_import_aioseo_settings_site" />';
            wp_nonce_field('wpsn_import_aioseo_settings_site');

            echo '<p style="margin:10px 0 6px;"><strong>' . esc_html__('Import options', 'wp-seo-next') . '</strong></p>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_templates" value="1" checked /> ' . esc_html__('Templates (title/description/author) (best-effort)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_separator" value="1" checked /> ' . esc_html__('Separator (best-effort)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_schema" value="1" checked /> ' . esc_html__('Organization name/logo (Schema defaults) (best-effort)', 'wp-seo-next');
            echo '</label>';
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_social" value="1" checked /> ' . esc_html__('Social default image (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="import_breadcrumbs" value="1" checked /> ' . esc_html__('Breadcrumbs (enabled + home label + separator) (best-effort)', 'wp-seo-next');
            echo '</label>';

            echo '<label style="display:block;margin:8px 0 2px 0;">';
            echo '<input type="checkbox" name="overwrite" value="1" /> ' . esc_html__('Overwrite existing WP SEO Next values (recommended OFF).', 'wp-seo-next');
            echo '</label>';

            submit_button(__('Import from AIOSEO (site)', 'wp-seo-next'), 'secondary', 'submit', false);
            echo '</form>';
        }
        echo '</div>';

        // --- Import Yoast settings (global) ---
        echo '<hr />';
        echo '<h2>' . esc_html__('Import Yoast settings export', 'wp-seo-next') . '</h2>';
        echo '<p>' . esc_html__('Upload a Yoast SEO settings export file (.ini / .txt) to import global settings into WP SEO Next: templates, separator, Organization name/logo, Social default image, breadcrumb labels, IndexNow key and more (best-effort).', 'wp-seo-next') . '</p>';
        echo '<p class="description">' . esc_html__('Note: Yoast export files do NOT contain per-post SEO data. Use the Migration tool below to import per-post fields (title/description/canonical/robots).', 'wp-seo-next') . '</p>';

        // Optional report box.
        $yoastReportId = !empty($_GET['yoast_report']) ? sanitize_text_field((string) wp_unslash($_GET['yoast_report'])) : '';
        if ($yoastReportId !== '') {
            $rep = get_transient('wpsn_yoast_import_' . $yoastReportId);
            if (is_array($rep)) {
                echo '<div class="notice notice-info" style="padding:12px;">';
                echo '<p><strong>' . esc_html__('Yoast import report', 'wp-seo-next') . '</strong></p>';

                $applied = isset($rep['applied']) && is_array($rep['applied']) ? $rep['applied'] : [];
                $skipped = isset($rep['skipped']) && is_array($rep['skipped']) ? $rep['skipped'] : [];
                $warnings = isset($rep['warnings']) && is_array($rep['warnings']) ? $rep['warnings'] : [];

                echo '<p>' . esc_html(sprintf(__('Applied: %d · Skipped: %d · Warnings: %d', 'wp-seo-next'), count($applied), count($skipped), count($warnings))) . '</p>';

                if (!empty($warnings)) {
                    echo '<ul style="margin:0 0 0 18px;list-style:disc;">';
                    foreach (array_slice($warnings, 0, 8) as $w) {
                        echo '<li>' . esc_html((string) $w) . '</li>';
                    }
                    if (count($warnings) > 8) {
                        echo '<li>' . esc_html__('…', 'wp-seo-next') . '</li>';
                    }
                    echo '</ul>';
                }

                $snapshotId = (int) ($rep['snapshot_id'] ?? 0);
                $undoRunId  = (string) ($rep['undo_run_id'] ?? '');
                if ($snapshotId > 0) {
                    echo '<p class="description" style="margin-top:10px;">' . esc_html(sprintf(__('Auto snapshot: #%d', 'wp-seo-next'), $snapshotId)) . '</p>';
                }
                if ($undoRunId !== '') {
                    echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-top:8px;">';
                    echo '<input type="hidden" name="action" value="wpsn_undo_run" />';
                    echo '<input type="hidden" name="run_id" value="' . esc_attr($undoRunId) . '" />';
                    echo '<input type="hidden" name="return" value="' . esc_attr($this->pageUrl(['yoast_report' => $yoastReportId])) . '" />';
                    wp_nonce_field('wpsn_undo_run_' . $undoRunId, 'wpsn_nonce');
                    echo '<button type="submit" class="button button-secondary" style="border-color:#b32d2e;color:#b32d2e;" onclick="return confirm(' . wp_json_encode(__('Rollback to auto snapshot? This will overwrite current settings.', 'wp-seo-next')) . ');">' . esc_html__('Rollback to auto snapshot', 'wp-seo-next') . '</button>';
                    echo '</form>';
                }

                echo '</div>';
            }
        }

        echo '<form method="post" enctype="multipart/form-data" action="' . esc_url(admin_url('admin-post.php')) . '">';
        echo '<input type="hidden" name="action" value="wpsn_import_yoast_settings" />';
        wp_nonce_field('wpsn_import_yoast_settings');
        echo '<input type="file" name="yoast_export_file" accept=".ini,.txt" required />';

        echo '<p style="margin-top:10px;"><strong>' . esc_html__('Import options', 'wp-seo-next') . '</strong></p>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_templates" value="1" checked /> ' . esc_html__('Templates (title/description/author)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_separator" value="1" checked /> ' . esc_html__('Separator', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_schema" value="1" checked /> ' . esc_html__('Organization name/logo/social profiles (Schema defaults)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_social" value="1" checked /> ' . esc_html__('Social default image', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_robots" value="1" checked /> ' . esc_html__('Robots defaults (archives / terms / paged) (best-effort)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_local" value="1" checked /> ' . esc_html__('Local SEO business fields (name / phone / email / address) (best-effort)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_indexnow" value="1" checked /> ' . esc_html__('IndexNow key (if present in export)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_verification" value="1" checked /> ' . esc_html__('Site verification codes (Google/Bing/Yandex/Baidu/Ahrefs) (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_breadcrumbs" value="1" checked /> ' . esc_html__('Breadcrumbs (enabled + labels + separator) (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_archive_controls" value="1" checked /> ' . esc_html__('Archive controls (author/date/attachment pages) (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_cleanup" value="1" checked /> ' . esc_html__('Head cleanup / hardening toggles (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_search_urls" value="1" checked /> ' . esc_html__('Search URL canonicalization / search crawl cleanup (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_ai_readiness" value="1" /> ' . esc_html__('AI Search Readiness (llms.txt + AI crawler policies) (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:8px 0 2px 0;">';
        echo '<input type="checkbox" name="overwrite" value="1" /> ' . esc_html__('Overwrite existing WP SEO Next values (recommended OFF).', 'wp-seo-next');
        echo '</label>';

        submit_button(__('Import Yoast settings', 'wp-seo-next'), 'secondary', 'submit', false);
        echo '</form>';

        // --- Import Rank Math settings (global) ---
        echo '<hr />';
        echo '<h2>' . esc_html__('Import Rank Math settings export', 'wp-seo-next') . '</h2>';
        echo '<p>' . esc_html__('Upload a Rank Math settings export file (.json) to import global settings into WP SEO Next: separator, templates, Organization name/logo and Social default image (best-effort).', 'wp-seo-next') . '</p>';
        echo '<p class="description">' . esc_html__('Note: Rank Math export files do NOT contain per-post SEO data. Use the Migration tool below to import per-post fields (title/description/canonical/robots).', 'wp-seo-next') . '</p>';

        $rmReportId = !empty($_GET['rankmath_report']) ? sanitize_text_field((string) wp_unslash($_GET['rankmath_report'])) : '';
        if ($rmReportId !== '') {
            $rep = get_transient('wpsn_rankmath_import_' . $rmReportId);
            if (is_array($rep)) {
                echo '<div class="notice notice-info" style="padding:12px;">';
                echo '<p><strong>' . esc_html__('Rank Math import report', 'wp-seo-next') . '</strong></p>';

                $applied = isset($rep['applied']) && is_array($rep['applied']) ? $rep['applied'] : [];
                $skipped = isset($rep['skipped']) && is_array($rep['skipped']) ? $rep['skipped'] : [];
                $warnings = isset($rep['warnings']) && is_array($rep['warnings']) ? $rep['warnings'] : [];

                echo '<p>' . esc_html(sprintf(__('Applied: %d · Skipped: %d · Warnings: %d', 'wp-seo-next'), count($applied), count($skipped), count($warnings))) . '</p>';

                if (!empty($warnings)) {
                    echo '<ul style="margin:0 0 0 18px;list-style:disc;">';
                    foreach (array_slice($warnings, 0, 8) as $w) {
                        echo '<li>' . esc_html((string) $w) . '</li>';
                    }
                    if (count($warnings) > 8) {
                        echo '<li>' . esc_html__('…', 'wp-seo-next') . '</li>';
                    }
                    echo '</ul>';
                }

                $snapshotId = (int) ($rep['snapshot_id'] ?? 0);
                $undoRunId  = (string) ($rep['undo_run_id'] ?? '');
                if ($snapshotId > 0) {
                    echo '<p class="description" style="margin-top:10px;">' . esc_html(sprintf(__('Auto snapshot: #%d', 'wp-seo-next'), $snapshotId)) . '</p>';
                }
                if ($undoRunId !== '') {
                    echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-top:8px;">';
                    echo '<input type="hidden" name="action" value="wpsn_undo_run" />';
                    echo '<input type="hidden" name="run_id" value="' . esc_attr($undoRunId) . '" />';
                    echo '<input type="hidden" name="return" value="' . esc_attr($this->pageUrl(['rankmath_report' => $rmReportId])) . '" />';
                    wp_nonce_field('wpsn_undo_run_' . $undoRunId, 'wpsn_nonce');
                    echo '<button type="submit" class="button button-secondary" style="border-color:#b32d2e;color:#b32d2e;" onclick="return confirm(' . wp_json_encode(__('Rollback to auto snapshot? This will overwrite current settings.', 'wp-seo-next')) . ');">' . esc_html__('Rollback to auto snapshot', 'wp-seo-next') . '</button>';
                    echo '</form>';
                }

                echo '</div>';
            }
        }

        echo '<form method="post" enctype="multipart/form-data" action="' . esc_url(admin_url('admin-post.php')) . '">';
        echo '<input type="hidden" name="action" value="wpsn_import_rankmath_settings" />';
        wp_nonce_field('wpsn_import_rankmath_settings');
        echo '<input type="file" name="rankmath_export_file" accept="application/json" required />';

        echo '<p style="margin-top:10px;"><strong>' . esc_html__('Import options', 'wp-seo-next') . '</strong></p>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_templates" value="1" checked /> ' . esc_html__('Templates (title/description/author)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_separator" value="1" checked /> ' . esc_html__('Separator', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_schema" value="1" checked /> ' . esc_html__('Organization name/logo + SameAs URLs (Schema defaults)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_social" value="1" checked /> ' . esc_html__('Social default image', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_breadcrumbs" value="1" checked /> ' . esc_html__('Breadcrumbs (enabled + home label + separator) (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:8px 0 2px 0;">';
        echo '<input type="checkbox" name="overwrite" value="1" /> ' . esc_html__('Overwrite existing WP SEO Next values (recommended OFF).', 'wp-seo-next');
        echo '</label>';

        submit_button(__('Import Rank Math settings', 'wp-seo-next'), 'secondary', 'submit', false);
        echo '</form>';

        // --- Import SEOPress settings (global) ---
        echo '<hr />';
        echo '<h2>' . esc_html__('Import SEOPress settings export', 'wp-seo-next') . '</h2>';
        echo '<p>' . esc_html__('Upload a SEOPress export file (.json) to import global settings into WP SEO Next (best-effort).', 'wp-seo-next') . '</p>';
        echo '<p class="description">' . esc_html__('Note: SEOPress export files do NOT contain per-post SEO data. Use the Migration tool below to import per-post fields (title/description/canonical/robots).', 'wp-seo-next') . '</p>';

        $spReportId = !empty($_GET['seopress_report']) ? sanitize_text_field((string) wp_unslash($_GET['seopress_report'])) : '';
        if ($spReportId !== '') {
            $rep = get_transient('wpsn_seopress_import_' . $spReportId);
            if (is_array($rep)) {
                echo '<div class="notice notice-info" style="padding:12px;">';
                echo '<p><strong>' . esc_html__('SEOPress import report', 'wp-seo-next') . '</strong></p>';

                $applied = isset($rep['applied']) && is_array($rep['applied']) ? $rep['applied'] : [];
                $skipped = isset($rep['skipped']) && is_array($rep['skipped']) ? $rep['skipped'] : [];
                $warnings = isset($rep['warnings']) && is_array($rep['warnings']) ? $rep['warnings'] : [];

                echo '<p>' . esc_html(sprintf(__('Applied: %d · Skipped: %d · Warnings: %d', 'wp-seo-next'), count($applied), count($skipped), count($warnings))) . '</p>';

                if (!empty($warnings)) {
                    echo '<ul style="margin:0 0 0 18px;list-style:disc;">';
                    foreach (array_slice($warnings, 0, 8) as $w) {
                        echo '<li>' . esc_html((string) $w) . '</li>';
                    }
                    if (count($warnings) > 8) {
                        echo '<li>' . esc_html__('…', 'wp-seo-next') . '</li>';
                    }
                    echo '</ul>';
                }

                $snapshotId = (int) ($rep['snapshot_id'] ?? 0);
                $undoRunId  = (string) ($rep['undo_run_id'] ?? '');
                if ($snapshotId > 0) {
                    echo '<p class="description" style="margin-top:10px;">' . esc_html(sprintf(__('Auto snapshot: #%d', 'wp-seo-next'), $snapshotId)) . '</p>';
                }
                if ($undoRunId !== '') {
                    echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-top:8px;">';
                    echo '<input type="hidden" name="action" value="wpsn_undo_run" />';
                    echo '<input type="hidden" name="run_id" value="' . esc_attr($undoRunId) . '" />';
                    echo '<input type="hidden" name="return" value="' . esc_attr($this->pageUrl(['seopress_report' => $seopressReportId])) . '" />';
                    wp_nonce_field('wpsn_undo_run_' . $undoRunId, 'wpsn_nonce');
                    echo '<button type="submit" class="button button-secondary" style="border-color:#b32d2e;color:#b32d2e;" onclick="return confirm(' . wp_json_encode(__('Rollback to auto snapshot? This will overwrite current settings.', 'wp-seo-next')) . ');">' . esc_html__('Rollback to auto snapshot', 'wp-seo-next') . '</button>';
                    echo '</form>';
                }

                echo '</div>';
            }
        }

        echo '<form method="post" enctype="multipart/form-data" action="' . esc_url(admin_url('admin-post.php')) . '">';
        echo '<input type="hidden" name="action" value="wpsn_import_seopress_settings" />';
        wp_nonce_field('wpsn_import_seopress_settings');
        echo '<input type="file" name="seopress_export_file" accept="application/json" required />';

        echo '<p style="margin-top:10px;"><strong>' . esc_html__('Import options', 'wp-seo-next') . '</strong></p>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_templates" value="1" checked /> ' . esc_html__('Templates (title/description/author) (best-effort)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_separator" value="1" checked /> ' . esc_html__('Separator', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_schema" value="1" checked /> ' . esc_html__('Organization name/logo (Schema defaults) (best-effort)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_social" value="1" checked /> ' . esc_html__('Social default image (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_breadcrumbs" value="1" checked /> ' . esc_html__('Breadcrumbs (enabled + home label + separator) (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:8px 0 2px 0;">';
        echo '<input type="checkbox" name="overwrite" value="1" /> ' . esc_html__('Overwrite existing WP SEO Next values (recommended OFF).', 'wp-seo-next');
        echo '</label>';

        submit_button(__('Import SEOPress settings', 'wp-seo-next'), 'secondary', 'submit', false);
        echo '</form>';

        // --- Import AIOSEO settings (global) ---
        echo '<hr />';
        echo '<h2>' . esc_html__('Import AIOSEO settings export', 'wp-seo-next') . '</h2>';
        echo '<p>' . esc_html__('Upload an AIOSEO export file (aioseo-export-settings*.json) to import global settings into WP SEO Next (best-effort).', 'wp-seo-next') . '</p>';
        echo '<p class="description">' . esc_html__('Note: AIOSEO export files do NOT contain per-post SEO data. Use the Migration tool below to import per-post fields (title/description/canonical/robots).', 'wp-seo-next') . '</p>';

        $aioReportId = !empty($_GET['aioseo_report']) ? sanitize_text_field((string) wp_unslash($_GET['aioseo_report'])) : '';
        if ($aioReportId !== '') {
            $rep = get_transient('wpsn_aioseo_import_' . $aioReportId);
            if (is_array($rep)) {
                echo '<div class="notice notice-info" style="padding:12px;">';
                echo '<p><strong>' . esc_html__('AIOSEO import report', 'wp-seo-next') . '</strong></p>';

                $applied = isset($rep['applied']) && is_array($rep['applied']) ? $rep['applied'] : [];
                $skipped = isset($rep['skipped']) && is_array($rep['skipped']) ? $rep['skipped'] : [];
                $warnings = isset($rep['warnings']) && is_array($rep['warnings']) ? $rep['warnings'] : [];

                echo '<p>' . esc_html(sprintf(__('Applied: %d · Skipped: %d · Warnings: %d', 'wp-seo-next'), count($applied), count($skipped), count($warnings))) . '</p>';

                if (!empty($warnings)) {
                    echo '<ul style="margin:0 0 0 18px;list-style:disc;">';
                    foreach (array_slice($warnings, 0, 8) as $w) {
                        echo '<li>' . esc_html((string) $w) . '</li>';
                    }
                    if (count($warnings) > 8) {
                        echo '<li>' . esc_html__('…', 'wp-seo-next') . '</li>';
                    }
                    echo '</ul>';
                }

                $snapshotId = (int) ($rep['snapshot_id'] ?? 0);
                $undoRunId  = (string) ($rep['undo_run_id'] ?? '');
                if ($snapshotId > 0) {
                    echo '<p class="description" style="margin-top:10px;">' . esc_html(sprintf(__('Auto snapshot: #%d', 'wp-seo-next'), $snapshotId)) . '</p>';
                }
                if ($undoRunId !== '') {
                    echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin-top:8px;">';
                    echo '<input type="hidden" name="action" value="wpsn_undo_run" />';
                    echo '<input type="hidden" name="run_id" value="' . esc_attr($undoRunId) . '" />';
                    echo '<input type="hidden" name="return" value="' . esc_attr($this->pageUrl(['aioseo_report' => $aioseoReportId])) . '" />';
                    wp_nonce_field('wpsn_undo_run_' . $undoRunId, 'wpsn_nonce');
                    echo '<button type="submit" class="button button-secondary" style="border-color:#b32d2e;color:#b32d2e;" onclick="return confirm(' . wp_json_encode(__('Rollback to auto snapshot? This will overwrite current settings.', 'wp-seo-next')) . ');">' . esc_html__('Rollback to auto snapshot', 'wp-seo-next') . '</button>';
                    echo '</form>';
                }

                echo '</div>';
            }
        }

        echo '<form method="post" enctype="multipart/form-data" action="' . esc_url(admin_url('admin-post.php')) . '">';
        echo '<input type="hidden" name="action" value="wpsn_import_aioseo_settings" />';
        wp_nonce_field('wpsn_import_aioseo_settings');
        echo '<input type="file" name="aioseo_export_file" accept="application/json" required />';

        echo '<p style="margin-top:10px;"><strong>' . esc_html__('Import options', 'wp-seo-next') . '</strong></p>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_templates" value="1" checked /> ' . esc_html__('Templates (title/description/author) (best-effort)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_separator" value="1" checked /> ' . esc_html__('Separator (best-effort)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_schema" value="1" checked /> ' . esc_html__('Organization name/logo (Schema defaults) (best-effort)', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_social" value="1" checked /> ' . esc_html__('Social default image (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="import_breadcrumbs" value="1" checked /> ' . esc_html__('Breadcrumbs (enabled + home label + separator) (best-effort)', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:8px 0 2px 0;">';
        echo '<input type="checkbox" name="overwrite" value="1" /> ' . esc_html__('Overwrite existing WP SEO Next values (recommended OFF).', 'wp-seo-next');
        echo '</label>';

        submit_button(__('Import AIOSEO settings', 'wp-seo-next'), 'secondary', 'submit', false);
        echo '</form>';

        // --- Migration ---
        echo '<hr />';
        echo '<h2>' . esc_html__('Migrate from other SEO plugins', 'wp-seo-next') . '</h2>';
        echo '<p>' . esc_html__('Imports per-post SEO fields: title, description, canonical, robots (noindex/nofollow) and focus keyword (best-effort for Yoast/RankMath). Optional: Social (OpenGraph/Twitter) overrides, Schema type and taxonomy SEO (Yoast). It will not delete data from the source plugin.', 'wp-seo-next') . '</p>';
        echo '<p><strong>' . esc_html__('Important:', 'wp-seo-next') . '</strong> ' . esc_html__('Avoid running two SEO plugins on the front-end at the same time to prevent duplicate tags.', 'wp-seo-next') . '</p>';

        $redirectMigUrl = admin_url('tools.php?page=wpsn-redirect-migration');
        echo '<p class="description">' . wp_kses_post(
            sprintf(
                /* translators: %s: URL */
                __('Need to migrate redirects too? Use <a href="%s">Redirect Migration</a>.', 'wp-seo-next'),
                esc_url($redirectMigUrl)
            )
        ) . '</p>';

        // Job status box.
        if (is_array($job)) {
            $this->renderJobBox($job, $autoRun);
        }

        // Migration form (disabled if job is running).
        $jobRunning = is_array($job) && ($job['stage'] ?? '') !== 'done';

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

        echo '<table class="form-table" role="presentation"><tbody>';

        echo '<tr><th scope="row"><label for="wpsn_source">' . esc_html__('Source', 'wp-seo-next') . '</label></th><td>';
        echo '<select name="source" id="wpsn_source" ' . disabled($jobRunning, true, false) . '>';
        foreach ($sources as $id => $info) {
            $label = $info['label'];
            if (!empty($info['detected'])) {
                $label .= ' — ' . __('detected', 'wp-seo-next');
            }
            echo '<option value="' . esc_attr($id) . '">' . esc_html($label) . '</option>';
        }
        echo '</select>';
        echo '</td></tr>';

        echo '<tr><th scope="row">' . esc_html__('Post types', 'wp-seo-next') . '</th><td>';
        foreach ($postTypes as $pt => $obj) {
            $label = $obj->labels->singular_name ?? $pt;
            echo '<label style="display:block;margin:2px 0;">';
            echo '<input type="checkbox" name="post_types[]" value="' . esc_attr($pt) . '" checked ' . disabled($jobRunning, true, false) . ' /> ';
            echo esc_html($label) . ' <code>' . esc_html($pt) . '</code>';
            echo '</label>';
        }
        echo '<p class="description">' . esc_html__('Only public post types are listed.', 'wp-seo-next') . '</p>';
        echo '</td></tr>';

        echo '<tr><th scope="row">' . esc_html__('Options', 'wp-seo-next') . '</th><td>';
        echo '<label style="display:block;margin:2px 0;">';
        echo '<input type="checkbox" name="overwrite" value="1" ' . disabled($jobRunning, true, false) . ' /> ';
        if ($brand !== '') {
            // translators: %s is brand name
            echo esc_html(sprintf(__('Overwrite existing %s values (recommended OFF for safety).', 'wp-seo-next'), $brand));
        } else {
            echo esc_html__('Overwrite existing values (recommended OFF for safety).', 'wp-seo-next');
        }
        echo '</label>';

        echo '<label style="display:block;margin:6px 0 2px 0;">';
        echo '<input type="checkbox" name="include_terms" value="1" ' . disabled($jobRunning, true, false) . ' /> ';
        echo esc_html__('Also import taxonomy SEO (categories/tags) when supported (Yoast, SEOPress).', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:6px 0 2px 0;">';
        echo '<input type="checkbox" name="include_social" value="1" checked ' . disabled($jobRunning, true, false) . ' /> ';
        echo esc_html__('Also import Social overrides (OpenGraph/Twitter) when supported.', 'wp-seo-next');
        echo '</label>';

        echo '<label style="display:block;margin:6px 0 2px 0;">';
        echo '<input type="checkbox" name="include_schema" value="1" checked ' . disabled($jobRunning, true, false) . ' /> ';
        echo esc_html__('Also import Schema type overrides when supported (Yoast, Rank Math, AIOSEO).', 'wp-seo-next');
        echo '</label>';

        echo '</td></tr>';

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

        if ($jobRunning) {
            submit_button(__('Migration is running…', 'wp-seo-next'), 'secondary', 'submit', false, ['disabled' => true]);
        } else {
            submit_button(__('Start migration', 'wp-seo-next'), 'primary', 'submit', false);
        }

        echo '</form>';

        // Auto-run driver (page).
        if ($autoRun && is_array($job) && ($job['stage'] ?? '') !== 'done') {
            $processUrl = $this->jobProcessUrl($job['id']);
            echo '<script>setTimeout(function(){ window.location.href = ' . wp_json_encode($processUrl) . '; }, 400);</script>';
        }

        echo '</div>';
    }

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

        check_admin_referer('wpsn_export_settings');

        $settings = $this->settingsRepo->getAll();

        // Do not export sensitive secrets.
        if (isset($settings['automation']['webhooks']['secret'])) {
            $settings['automation']['webhooks']['secret'] = '';
        }

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

        $json = wp_json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
        if (!is_string($json)) {
            wp_die(esc_html__('Failed to encode JSON.', 'wp-seo-next'));
        }

        $file = 'wpsn-settings-' . gmdate('Y-m-d') . '.json';

        /**
         * Fired when settings are exported from Tools → SEO Import/Export.
         *
         * @param int $userId
         * @param array<string,mixed> $meta
         */
        do_action('wpsn/import_export/settings_exported', (int) get_current_user_id(), [
            'version' => defined('WPSN_VERSION') ? (string) WPSN_VERSION : '',
        ]);

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

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

        check_admin_referer('wpsn_import_settings');

        if (empty($_FILES['wpsn_settings_file']) || !isset($_FILES['wpsn_settings_file']['tmp_name'])) {
            $this->redirectNotice('import_settings_invalid');
        }

        $file = $_FILES['wpsn_settings_file'];

        if (!empty($file['error'])) {
            $this->redirectNotice('import_settings_failed');
        }

        $contents = file_get_contents((string) $file['tmp_name']);
        if (!is_string($contents) || $contents === '') {
            $this->redirectNotice('import_settings_invalid');
        }

        // Basic size guard (JSON settings should be small).
        if (strlen($contents) > 1024 * 1024) {
            $this->redirectNotice('import_settings_invalid');
        }

        $decoded = json_decode($contents, true);
        if (!is_array($decoded)) {
            $this->redirectNotice('import_settings_invalid');
        }

        $settings = $decoded['settings'] ?? $decoded;
        if (!is_array($settings)) {
            $this->redirectNotice('import_settings_invalid');
        }

        // Preserve secrets that are not part of export/import.
        $current = $this->settingsRepo->getAll();
        $currentSecret = (string) ($current['automation']['webhooks']['secret'] ?? '');
        if (!isset($settings['automation']) || !is_array($settings['automation'])) {
            $settings['automation'] = [];
        }
        if (!isset($settings['automation']['webhooks']) || !is_array($settings['automation']['webhooks'])) {
            $settings['automation']['webhooks'] = [];
        }
        $settings['automation']['webhooks']['secret'] = $currentSecret;

        // Auto snapshot BEFORE applying imported settings (for one-click rollback).
        $snapshotId = 0;
        try {
            $snapshotId = (int) ConfigSnapshotService::createAutoSnapshot('Auto snapshot: before settings import (JSON)');
        } catch (\Throwable $e) {
            $snapshotId = 0;
        }

        $reportId = function_exists('wp_generate_uuid4') ? (string) wp_generate_uuid4() : uniqid('wpsn_', true);

        try {
            $this->settingsRepo->update($settings);
        } catch (\Throwable $e) {
            $this->logger->error('Settings import failed: ' . $e->getMessage());
            $this->redirectNotice('import_settings_failed');
        }

        // Record an undo run (snapshot restore) + store a short report.
        $undoRunId = '';
        if ($snapshotId > 0) {
            $undoRunId = UndoRuns::record([
                'type' => 'config_snapshot',
                'label' => 'Settings import (JSON)',
                'actor_user_id' => (int) get_current_user_id(),
                'meta' => [
                    'snapshot_id' => (int) $snapshotId,
                    'context' => 'settings_import_json',
                    'report_id' => $reportId,
                ],
            ]);
        }

        $settingsReport = [
            'id' => $reportId,
            'created_at' => gmdate('c'),
            'source' => 'settings_json',
            'snapshot_id' => (int) $snapshotId,
            'undo_run_id' => (string) $undoRunId,
        ];

        if (function_exists('set_transient')) {
            set_transient('wpsn_settings_import_' . $reportId, $settingsReport, self::JOB_TTL);
        }
        $this->recordImportAssistSettingsRestore($settingsReport);

        /**
         * Fired when settings are imported from Tools → SEO Import/Export.
         *
         * @param int $userId
         * @param array<string,mixed> $meta
         */
        $importedVersion = '';
        if (isset($decoded['version'])) {
            $importedVersion = is_string($decoded['version']) ? $decoded['version'] : '';
        }
        do_action('wpsn/import_export/settings_imported', (int) get_current_user_id(), [
            'imported_version' => $importedVersion,
            'snapshot_id' => (int) $snapshotId,
        ]);

        $this->redirectNotice('settings_imported', ['settings_report' => $reportId]);
    }

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

        check_admin_referer('wpsn_import_yoast_settings');

        if (empty($_FILES['yoast_export_file']) || !isset($_FILES['yoast_export_file']['tmp_name'])) {
            $this->redirectNotice('yoast_import_invalid');
        }

        $file = $_FILES['yoast_export_file'];
        if (!empty($file['error'])) {
            $this->redirectNotice('yoast_import_failed');
        }

        $contents = file_get_contents((string) $file['tmp_name']);
        if (!is_string($contents) || trim($contents) === '') {
            $this->redirectNotice('yoast_import_invalid');
        }

        // Size guard. Yoast exports are small.
        if (strlen($contents) > 2 * 1024 * 1024) {
            $this->redirectNotice('yoast_import_invalid');
        }

        $ignoredLines = 0;
        $ini = $this->parseYoastIniBestEffort((string) $contents, $ignoredLines);
        if (!is_array($ini)) {
            $this->redirectNotice('yoast_import_invalid');
        }

        // Basic signature check.
        if (!$this->yoastIniHasRequiredSections($ini)) {
            $this->redirectNotice('yoast_import_invalid');
        }

        $overwrite = !empty($_POST['overwrite']);
        $importTemplates = !empty($_POST['import_templates']);
        $importSeparator = !empty($_POST['import_separator']);
        $importSchema = !empty($_POST['import_schema']);
        $importSocial = !empty($_POST['import_social']);
        $importRobots = !empty($_POST['import_robots']);
        $importLocal = !empty($_POST['import_local']);
        $importIndexNow = !empty($_POST['import_indexnow']);
        $importVerification = !empty($_POST['import_verification']);
        $importBreadcrumbs = !empty($_POST['import_breadcrumbs']);
        $importArchiveControls = !empty($_POST['import_archive_controls']);
        $importCleanup = !empty($_POST['import_cleanup']);
        $importSearchUrls = !empty($_POST['import_search_urls']);
        $importAiReadiness = !empty($_POST['import_ai_readiness']);

        $reportId = function_exists('wp_generate_uuid4') ? wp_generate_uuid4() : uniqid('wpsn_yoast_', true);

        $report = [
            'id' => $reportId,
            'imported_at' => gmdate('c'),
            'overwrite' => $overwrite,
            'applied' => [],
            'skipped' => [],
            'not_mapped' => [],
            'warnings' => [],
        ];

        if ($ignoredLines > 0) {
            $report['warnings'][] = sprintf('Yoast export: ignored %d non-INI lines (copy/paste noise).', $ignoredLines);
        }

        $current = $this->settingsRepo->getAll();
        $defaults = $this->settingsRepo->defaults();

        // Safety: create an automatic snapshot before applying (for one-click rollback).
        $snapshotId = 0;
        try {
            $snapshotId = (int) ConfigSnapshotService::createAutoSnapshot(
                sprintf('Auto backup: before Yoast settings import (%s)', (string) $reportId),
                [
                    'reason' => 'yoast_settings_import',
                ]
            );
        } catch (\Throwable $e) {
            // Never block import.
            $snapshotId = 0;
        }

        $undoRunId = '';
        if ($snapshotId > 0) {
            $undoRunId = UndoRuns::record([
                'type' => 'config_snapshot',
                'label' => 'Yoast settings import (file)',
                'actor_user_id' => (int) get_current_user_id(),
                'meta' => [
                    'snapshot_id' => (int) $snapshotId,
                    'context' => 'yoast_settings_import_file',
                    'report_id' => (string) $reportId,
                ],
            ]);
        }
        $report['snapshot_id'] = (int) $snapshotId;
        $report['undo_run_id'] = (string) $undoRunId;

        /** @var array<string,mixed> $wpseo */
        $wpseo = isset($ini['wpseo']) && is_array($ini['wpseo']) ? $ini['wpseo'] : [];
        /** @var array<string,mixed> $titles */
        $titles = isset($ini['wpseo_titles']) && is_array($ini['wpseo_titles']) ? $ini['wpseo_titles'] : [];
        /** @var array<string,mixed> $social */
        $social = isset($ini['wpseo_social']) && is_array($ini['wpseo_social']) ? $ini['wpseo_social'] : [];

        if (empty($wpseo) && ($importIndexNow || $importVerification || $importAiReadiness || $importCleanup || $importSearchUrls)) {
            $report['warnings'][] = 'Yoast export: [wpseo] section is missing; IndexNow, verification tags, cleanup, search URL, and AI-readiness settings could not be imported from this file.';
        }

        // 1) Separator
        if ($importSeparator) {
            $rawSep = '';
            if (isset($ini['wpseo_titles']['separator'])) {
                $rawSep = is_string($ini['wpseo_titles']['separator']) ? (string) $ini['wpseo_titles']['separator'] : '';
            }
            $sep = $this->yoastSeparatorToChar($rawSep, $report);
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'separator', $sep, $overwrite, $report);
            }
        }

        // 2) Templates
        if ($importTemplates) {
            $t = $ini['wpseo_titles'];

            $titleTpl = isset($t['title-post']) && is_string($t['title-post']) ? trim((string) $t['title-post']) : '';
            if ($titleTpl === '' && isset($t['title-home-wpseo']) && is_string($t['title-home-wpseo'])) {
                $titleTpl = trim((string) $t['title-home-wpseo']);
            }
            if ($titleTpl !== '') {
                $converted = $this->yoastTemplateToWpsn($titleTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.title', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Yoast export: title template not found.';
            }

            $descTpl = isset($t['metadesc-post']) && is_string($t['metadesc-post']) ? trim((string) $t['metadesc-post']) : '';
            if ($descTpl === '' && isset($t['metadesc-home-wpseo']) && is_string($t['metadesc-home-wpseo'])) {
                $descTpl = trim((string) $t['metadesc-home-wpseo']);
            }
            if ($descTpl !== '') {
                $converted = $this->yoastTemplateToWpsn($descTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.description', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Yoast export: meta description template is empty (kept WP SEO Next default).';
            }

            $authorTitle = isset($t['title-author-wpseo']) && is_string($t['title-author-wpseo']) ? trim((string) $t['title-author-wpseo']) : '';
            if ($authorTitle !== '') {
                $converted = $this->yoastTemplateToWpsn($authorTitle);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.author_title', $converted, $overwrite, $report);
            }

            $authorDesc = isset($t['metadesc-author-wpseo']) && is_string($t['metadesc-author-wpseo']) ? trim((string) $t['metadesc-author-wpseo']) : '';
            if ($authorDesc !== '') {
                $converted = $this->yoastTemplateToWpsn($authorDesc);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.author_description', $converted, $overwrite, $report);
            }
        }

        // 3) Schema defaults (Organization name/logo)
        if ($importSchema) {
            $t = $titles;
            $who = isset($t['company_or_person']) && is_string($t['company_or_person']) ? trim((string) $t['company_or_person']) : '';
            $orgName = $this->yoastPreferredEntityName($t, $who);
            $logoUrl = $this->yoastPreferredEntityLogoUrl($t, $who);

            if ($orgName !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_name', $orgName, $overwrite, $report);
            }
            if ($logoUrl !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_logo_url', $logoUrl, $overwrite, $report);
            }

            $sameAs = $this->collectYoastSameAsUrls($social);
            if (!empty($sameAs)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_same_as', $sameAs, $overwrite, $report);
            }

            $this->applyYoastSchemaParitySettings($current, $defaults, $titles, $overwrite, $report, 'Yoast export');
        }

        // 4) Social default image
        if ($importSocial) {
            $t = $titles;

            $who = isset($t['company_or_person']) && is_string($t['company_or_person']) ? trim((string) $t['company_or_person']) : '';
            $img = $this->yoastPreferredDefaultSocialImageUrl($t, $social, $who);

            if ($img !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'social.default_image_url', $img, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Yoast export: default OpenGraph image not set.';
            }
        }

        // 4b) Robots defaults (best-effort)
        if ($importRobots) {
            $this->applyYoastRobotsDefaults($current, $defaults, $titles, $overwrite, $report);
        }

        // 4c) Local SEO business fields (best-effort)
        if ($importLocal) {
            $this->applyYoastLocalBusinessDefaults($current, $defaults, $titles, $overwrite, $report);
        }

        $this->appendYoastPortabilityWarnings($titles, $importRobots, $importSchema, $importLocal, $report, 'Yoast export', $importArchiveControls);

        // 5) IndexNow key
        if ($importIndexNow) {
            $yoastEnabled = !empty($wpseo['enable_index_now']);
            $yoastKey = isset($wpseo['index_now_key']) && is_string($wpseo['index_now_key']) ? trim((string) $wpseo['index_now_key']) : '';

            $secrets = new IndexNowSecretsRepository();
            $hasKey = $secrets->hasKey();

            if ($yoastKey !== '') {
                if ($overwrite || !$hasKey) {
                    $secrets->setKey($yoastKey);
                    $report['applied'][] = 'indexnow.key (stored)';
                    $hasKey = $secrets->hasKey();
                } else {
                    $report['skipped'][] = 'indexnow.key (kept existing)';
                }
            } elseif ($yoastEnabled) {
                $report['warnings'][] = 'Yoast export: IndexNow enabled, but key is empty. Generate a key in WP SEO Next → IndexNow.';
            }

            // Enable module setting if we have a key (or overwrite is on).
            if ($yoastEnabled && $hasKey) {
                $this->maybeApplySettingScalar($current, $defaults, 'indexnow.enabled', true, true, $report);
            }
        }

        // 5b) Site verification meta tags
        if ($importVerification) {
            $wpseo = isset($ini['wpseo']) && is_array($ini['wpseo']) ? $ini['wpseo'] : [];

            $map = [
                'googleverify' => 'verification.google',
                'msverify' => 'verification.bing',
                'yandexverify' => 'verification.yandex',
                'baiduverify' => 'verification.baidu',
                'ahrefsverify' => 'verification.ahrefs',
            ];

            foreach ($map as $src => $dst) {
                if (!is_array($wpseo) || !array_key_exists($src, $wpseo)) {
                    continue;
                }
                $val = is_string($wpseo[$src]) ? trim((string) $wpseo[$src]) : trim((string) ($wpseo[$src] ?? ''));
                $this->maybeApplySettingScalar($current, $defaults, $dst, $val, $overwrite, $report);
            }
        }

        // 6) Breadcrumbs
        if ($importBreadcrumbs) {
            $this->applyYoastBreadcrumbSettings($current, $defaults, $titles, $overwrite, $report);
        }

        // 6b) Archive controls (best-effort)
        if ($importArchiveControls) {
            $this->applyYoastArchiveControls($current, $defaults, $wpseo, $titles, $overwrite, $report, 'Yoast export');
        }

        // 6c) Head cleanup / hardening (best-effort)
        if ($importCleanup) {
            $this->applyYoastHeadCleanupSettings($current, $defaults, $wpseo, $overwrite, $report, 'Yoast export');
        }

        // 6d) Search URL canonicalization / search crawl cleanup (best-effort)
        if ($importSearchUrls) {
            $this->applyYoastSearchUrlSettings($current, $defaults, array_merge($wpseo, $titles), $overwrite, $report, 'Yoast export');
        }

        // 7) AI Search Readiness (best-effort)
        if ($importAiReadiness) {
            if (array_key_exists('enable_llms_txt', $wpseo)) {
                $this->maybeApplySettingScalar($current, $defaults, 'ai_readiness.llms.enabled', !empty($wpseo['enable_llms_txt']), $overwrite, $report);
            }

            // Yoast uses deny_* flags: map them to allowlist values.
            if (array_key_exists('deny_gptbot_crawling', $wpseo)) {
                $deny = !empty($wpseo['deny_gptbot_crawling']);
                $this->maybeApplySettingScalar($current, $defaults, 'ai_readiness.bots.allow.gptbot', !$deny, $overwrite, $report);
            }
            if (array_key_exists('deny_google_extended_crawling', $wpseo)) {
                $deny = !empty($wpseo['deny_google_extended_crawling']);
                $this->maybeApplySettingScalar($current, $defaults, 'ai_readiness.bots.allow.google_extended', !$deny, $overwrite, $report);
            }
        }

        try {
            $this->settingsRepo->update($current);
        } catch (\Throwable $e) {
            $this->logger->error('Yoast settings import failed: ' . $e->getMessage());
            $this->redirectNotice('yoast_import_failed');
        }

        // Persist report for display.
        set_transient('wpsn_yoast_import_' . $reportId, $report, self::JOB_TTL);
        $this->recordImportAssistSettingsImport('yoast', $report, 'file');

        $this->redirectNotice('yoast_imported', ['yoast_report' => $reportId]);
    }

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

        check_admin_referer('wpsn_import_rankmath_settings');

        if (empty($_FILES['rankmath_export_file']) || !is_array($_FILES['rankmath_export_file'])) {
            $this->redirectNotice('rankmath_import_invalid');
        }

        $file = (array) $_FILES['rankmath_export_file'];
        $tmp = isset($file['tmp_name']) ? (string) $file['tmp_name'] : '';
        if ($tmp === '' || !is_readable($tmp)) {
            $this->redirectNotice('rankmath_import_invalid');
        }

        $raw = file_get_contents($tmp);
        if (!is_string($raw) || trim($raw) === '') {
            $this->redirectNotice('rankmath_import_invalid');
        }

        $data = json_decode($raw, true);
        if (!is_array($data) || empty($data['titles']) || !is_array($data['titles'])) {
            $this->redirectNotice('rankmath_import_invalid');
        }

        $importTemplates = !empty($_POST['import_templates']);
        $importSeparator = !empty($_POST['import_separator']);
        $importSchema = !empty($_POST['import_schema']);
        $importSocial = !empty($_POST['import_social']);
        $importBreadcrumbs = !empty($_POST['import_breadcrumbs']);
        $overwrite = !empty($_POST['overwrite']);

        $reportId = wp_generate_uuid4();
        $report = [
            'applied' => [],
            'skipped' => [],
            'warnings' => [],
        ];

        $current = $this->settingsRepo->getAll();
        $defaults = $this->settingsRepo->defaults();

        // Safety: create an automatic snapshot before applying (for one-click rollback).
        $snapshotId = 0;
        try {
            $snapshotId = (int) ConfigSnapshotService::createAutoSnapshot(
                sprintf('Auto backup: before Rank Math settings import (%s)', (string) $reportId),
                [
                    'reason' => 'rankmath_settings_import',
                ]
            );
        } catch (\Throwable $e) {
            // Never block import.
            $snapshotId = 0;
        }

        $undoRunId = '';
        if ($snapshotId > 0) {
            $undoRunId = UndoRuns::record([
                'type' => 'config_snapshot',
                'label' => 'Rank Math settings import (file)',
                'actor_user_id' => (int) get_current_user_id(),
                'meta' => [
                    'snapshot_id' => (int) $snapshotId,
                    'context' => 'rankmath_settings_import_file',
                    'report_id' => (string) $reportId,
                ],
            ]);
        }
        $report['snapshot_id'] = (int) $snapshotId;
        $report['undo_run_id'] = (string) $undoRunId;

        $t = $data['titles'];

        // 1) Separator
        if ($importSeparator) {
            $sep = isset($t['title_separator']) && is_string($t['title_separator']) ? trim((string) $t['title_separator']) : '';
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'separator', $sep, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Rank Math export: separator not found.';
            }
        }

        // 2) Templates
        if ($importTemplates) {
            $titleTpl = isset($t['pt_post_title']) && is_string($t['pt_post_title']) ? trim((string) $t['pt_post_title']) : '';
            if ($titleTpl === '' && isset($t['homepage_title']) && is_string($t['homepage_title'])) {
                $titleTpl = trim((string) $t['homepage_title']);
            }
            if ($titleTpl !== '') {
                $converted = $this->rankMathTemplateToWpsn($titleTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.title', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Rank Math export: title template not found.';
            }

            $descTpl = isset($t['pt_post_description']) && is_string($t['pt_post_description']) ? trim((string) $t['pt_post_description']) : '';
            if ($descTpl === '' && isset($t['homepage_description']) && is_string($t['homepage_description'])) {
                $descTpl = trim((string) $t['homepage_description']);
            }
            if ($descTpl !== '') {
                $converted = $this->rankMathTemplateToWpsn($descTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.description', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Rank Math export: description template not found.';
            }

            $authorTitle = isset($t['author_archive_title']) && is_string($t['author_archive_title']) ? trim((string) $t['author_archive_title']) : '';
            if ($authorTitle !== '') {
                $converted = $this->rankMathTemplateToWpsn($authorTitle);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.author_title', $converted, $overwrite, $report);
            }

            // Rank Math exports do not always include a dedicated author meta description.
        }

        // 3) Schema defaults (Organization name/logo + SameAs)
        if ($importSchema) {
            $orgName = isset($t['knowledgegraph_name']) && is_string($t['knowledgegraph_name']) ? trim((string) $t['knowledgegraph_name']) : '';
            $logoUrl = isset($t['knowledgegraph_logo']) && is_string($t['knowledgegraph_logo']) ? trim((string) $t['knowledgegraph_logo']) : '';

            if ($orgName !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_name', $orgName, $overwrite, $report);
            }
            if ($logoUrl !== '' && $this->looksLikeUrl($logoUrl)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_logo_url', $logoUrl, $overwrite, $report);
            }

            // SameAs URLs: collect known social profile URLs.
            $sameAs = [];
            foreach ($t as $k => $v) {
                if (!is_string($k) || !is_string($v)) {
                    continue;
                }
                $kk = strtolower((string) $k);
                if (strpos($kk, 'social_url_') === 0) {
                    $u = trim((string) $v);
                    if ($u !== '' && $this->looksLikeUrl($u)) {
                        $sameAs[] = $u;
                    }
                }
            }

            $sameAs = array_values(array_unique($sameAs));
            if (!empty($sameAs)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_same_as', $sameAs, $overwrite, $report);
            }
        }

        // 4) Social default image
        if ($importSocial) {
            $img = '';
            if (isset($t['open_graph_image']) && is_string($t['open_graph_image'])) {
                $img = trim((string) $t['open_graph_image']);
            }
            if ($img === '' && isset($t['knowledgegraph_logo']) && is_string($t['knowledgegraph_logo'])) {
                $img = trim((string) $t['knowledgegraph_logo']);
            }

            if ($img !== '' && $this->looksLikeUrl($img)) {
                $this->maybeApplySettingScalar($current, $defaults, 'social.default_image_url', $img, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Rank Math export: default social image not found.';
            }
        }

        // 5) Breadcrumbs (best-effort)
        if ($importBreadcrumbs) {
            $general = [];
            if (isset($data['general'])) {
                $general = is_array($data['general']) ? $data['general'] : $this->normalizeMaybeSerialized($data['general']);
            }
            if (!is_array($general)) {
                $general = [];
            }

            $payload = [
                'titles' => $t,
                'general' => $general,
            ];

            $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumb', 'enable']);
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumbs', 'enable']);
            }
            if ($enabled === null) {
                // Some exports use a plain "breadcrumbs" toggle.
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumbs']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumb']);
            }

            $home = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'home']);
            if ($home === '') {
                $home = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumbs', 'home']);
            }

            $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'sep']);
            if ($sep === '') {
                $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'separator']);
            }
            if ($sep === '') {
                $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumbs', 'separator']);
            }
            if ($sep !== '' && strlen($sep) > 16) {
                $sep = substr($sep, 0, 16);
            }

            if ($enabled === null) {
                if ($home !== '' || $sep !== '') {
                    $enabled = true;
                    $report['warnings'][] = 'Rank Math export: breadcrumbs enabled flag not found; assumed enabled because breadcrumb fields exist.';
                } else {
                    $report['warnings'][] = 'Rank Math export: breadcrumbs settings not found.';
                }
            }

            if ($enabled !== null) {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.ui_enabled', (bool) $enabled, $overwrite, $report);
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.schema_enabled', (bool) $enabled, $overwrite, $report);
            }
            if ($home !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.home_label', $home, $overwrite, $report);
            }
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.separator', $sep, $overwrite, $report);
            }
        }

        try {
            $this->settingsRepo->update($current);
        } catch (\Throwable $e) {
            $this->logger->error('Rank Math settings import failed: ' . $e->getMessage());
            $this->redirectNotice('rankmath_import_failed');
        }

        set_transient('wpsn_rankmath_import_' . $reportId, $report, self::JOB_TTL);
        $this->recordImportAssistSettingsImport('rankmath', $report, 'file');
        $this->redirectNotice('rankmath_imported', ['rankmath_report' => $reportId]);
    }

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

        check_admin_referer('wpsn_import_seopress_settings');

        if (empty($_FILES['seopress_export_file']) || !is_array($_FILES['seopress_export_file'])) {
            $this->redirectNotice('seopress_import_invalid');
        }

        $file = (array) $_FILES['seopress_export_file'];
        $tmp = isset($file['tmp_name']) ? (string) $file['tmp_name'] : '';
        if ($tmp === '' || !is_readable($tmp)) {
            $this->redirectNotice('seopress_import_invalid');
        }

        $raw = file_get_contents($tmp);
        if (!is_string($raw) || trim($raw) === '') {
            $this->redirectNotice('seopress_import_invalid');
        }

        $data = json_decode($raw, true);
        if (!is_array($data) || (empty($data['seopress_titles_option_name']) && empty($data['seopress_social_option_name']))) {
            $this->redirectNotice('seopress_import_invalid');
        }

        $importTemplates = !empty($_POST['import_templates']);
        $importSeparator = !empty($_POST['import_separator']);
        $importSchema = !empty($_POST['import_schema']);
        $importSocial = !empty($_POST['import_social']);
        $importBreadcrumbs = !empty($_POST['import_breadcrumbs']);
        $overwrite = !empty($_POST['overwrite']);

        $reportId = wp_generate_uuid4();
        $report = [
            'applied' => [],
            'skipped' => [],
            'warnings' => [],
        ];

        $current = $this->settingsRepo->getAll();
        $defaults = $this->settingsRepo->defaults();

        // Safety: create an automatic snapshot before applying (for one-click rollback).
        $snapshotId = 0;
        try {
            $snapshotId = (int) ConfigSnapshotService::createAutoSnapshot(
                sprintf('Auto backup: before SEOPress settings import (%s)', (string) $reportId),
                [
                    'reason' => 'seopress_settings_import',
                ]
            );
        } catch (\Throwable $e) {
            // Never block import.
            $snapshotId = 0;
        }

        $undoRunId = '';
        if ($snapshotId > 0) {
            $undoRunId = UndoRuns::record([
                'type' => 'config_snapshot',
                'label' => 'SEOPress settings import (file)',
                'actor_user_id' => (int) get_current_user_id(),
                'meta' => [
                    'snapshot_id' => (int) $snapshotId,
                    'context' => 'seopress_settings_import_file',
                    'report_id' => (string) $reportId,
                ],
            ]);
        }
        $report['snapshot_id'] = (int) $snapshotId;
        $report['undo_run_id'] = (string) $undoRunId;

        $titles = $this->normalizeMaybeSerialized($data['seopress_titles_option_name'] ?? []);
        $social = $this->normalizeMaybeSerialized($data['seopress_social_option_name'] ?? []);
        $pro = $this->normalizeMaybeSerialized($data['seopress_pro_option_name'] ?? []);
        if (!is_array($pro)) {
            $pro = [];
        }

        // 1) Separator
        if ($importSeparator) {
            $sep = '';
            if (is_array($titles)) {
                $sep = isset($titles['seopress_titles_sep']) && is_string($titles['seopress_titles_sep']) ? trim((string) $titles['seopress_titles_sep']) : '';
                if ($sep === '') {
                    $sep = $this->findFirstStringValueByKeyContains($titles, ['sep']);
                }
            }

            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'separator', $sep, $overwrite, $report);
            } else {
                $report['warnings'][] = 'SEOPress export: separator not found (kept WP SEO Next default).';
            }
        }

        // 2) Templates (best-effort)
        if ($importTemplates) {
            $titleTpl = '';
            $descTpl = '';

            if (is_array($titles)) {
                // Common keys (best-effort, SEOPress internal keys vary by version).
                $candidatesTitle = [
                    'seopress_titles_single_title',
                    'seopress_titles_singular_title',
                    'seopress_titles_home_site_title',
                ];
                foreach ($candidatesTitle as $k) {
                    if (isset($titles[$k]) && is_string($titles[$k]) && trim((string) $titles[$k]) !== '') {
                        $titleTpl = trim((string) $titles[$k]);
                        break;
                    }
                }

                $candidatesDesc = [
                    'seopress_titles_single_desc',
                    'seopress_titles_singular_desc',
                    'seopress_titles_home_site_desc',
                ];
                foreach ($candidatesDesc as $k) {
                    if (isset($titles[$k]) && is_string($titles[$k]) && trim((string) $titles[$k]) !== '') {
                        $descTpl = trim((string) $titles[$k]);
                        break;
                    }
                }
            }

            if ($titleTpl !== '') {
                $converted = $this->seopressTemplateToWpsn($titleTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.title', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'SEOPress export: title template not detected.';
            }

            if ($descTpl !== '') {
                $converted = $this->seopressTemplateToWpsn($descTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.description', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'SEOPress export: description template not detected.';
            }
        }

        // 3) Schema defaults (best-effort)
        if ($importSchema) {
            $orgName = '';
            $logoUrl = '';

            if (is_array($social)) {
                $orgName = $this->findFirstStringValueByKeyContains($social, ['knowledge', 'name']);
                if ($orgName === '') {
                    $orgName = $this->findFirstStringValueByKeyContains($social, ['company', 'name']);
                }

                $logoUrl = $this->findFirstUrlValueByKeyContains($social, ['knowledge', 'img']);
                if ($logoUrl === '') {
                    $logoUrl = $this->findFirstUrlValueByKeyContains($social, ['company', 'logo']);
                }
            }

            if ($orgName !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_name', $orgName, $overwrite, $report);
            }
            if ($logoUrl !== '' && $this->looksLikeUrl($logoUrl)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_logo_url', $logoUrl, $overwrite, $report);
            }
        }

        // 4) Social default image (best-effort)
        if ($importSocial) {
            $img = '';
            if (is_array($social)) {
                $img = $this->findFirstUrlValueByKeyContains($social, ['og', 'default']);
                if ($img === '') {
                    $img = $this->findFirstUrlValueByKeyContains($social, ['default', 'image']);
                }
                if ($img === '') {
                    $img = $this->findFirstUrlValueByKeyContains($social, ['logo']);
                }
            }

            if ($img !== '' && $this->looksLikeUrl($img)) {
                $this->maybeApplySettingScalar($current, $defaults, 'social.default_image_url', $img, $overwrite, $report);
            } else {
                $report['warnings'][] = 'SEOPress export: default social image not detected.';
            }
        }

        // 5) Breadcrumbs (best-effort)
        if ($importBreadcrumbs) {
            $payload = [
                'titles' => is_array($titles) ? $titles : [],
                'social' => is_array($social) ? $social : [],
                'pro' => is_array($pro) ? $pro : [],
            ];

            $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumb', 'enable']);
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumbs', 'enable']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumbs']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumb']);
            }

            $home = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'home']);
            if ($home === '') {
                $home = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumbs', 'home']);
            }

            $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'sep']);
            if ($sep === '') {
                $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'separator']);
            }
            if ($sep === '') {
                $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumbs', 'separator']);
            }
            if ($sep !== '' && strlen($sep) > 16) {
                $sep = substr($sep, 0, 16);
            }

            if ($enabled === null) {
                if ($home !== '' || $sep !== '') {
                    $enabled = true;
                    $report['warnings'][] = 'SEOPress export: breadcrumbs enabled flag not found; assumed enabled because breadcrumb fields exist.';
                } else {
                    $report['warnings'][] = 'SEOPress export: breadcrumbs settings not detected.';
                }
            }

            if ($enabled !== null) {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.ui_enabled', (bool) $enabled, $overwrite, $report);
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.schema_enabled', (bool) $enabled, $overwrite, $report);
            }
            if ($home !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.home_label', $home, $overwrite, $report);
            }
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.separator', $sep, $overwrite, $report);
            }
        }

        try {
            $this->settingsRepo->update($current);
        } catch (\Throwable $e) {
            $this->logger->error('SEOPress settings import failed: ' . $e->getMessage());
            $this->redirectNotice('seopress_import_failed');
        }

        set_transient('wpsn_seopress_import_' . $reportId, $report, self::JOB_TTL);
        $this->recordImportAssistSettingsImport('seopress', $report, 'file');
        $this->redirectNotice('seopress_imported', ['seopress_report' => $reportId]);
    }

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

        check_admin_referer('wpsn_import_aioseo_settings');

        if (empty($_FILES['aioseo_export_file']) || !is_array($_FILES['aioseo_export_file'])) {
            $this->redirectNotice('aioseo_import_invalid');
        }

        $file = (array) $_FILES['aioseo_export_file'];
        $tmp = isset($file['tmp_name']) ? (string) $file['tmp_name'] : '';
        if ($tmp === '' || !is_readable($tmp)) {
            $this->redirectNotice('aioseo_import_invalid');
        }

        $raw = file_get_contents($tmp);
        if (!is_string($raw) || trim($raw) === '') {
            $this->redirectNotice('aioseo_import_invalid');
        }

        $data = json_decode($raw, true);
        if (!is_array($data)) {
            $this->redirectNotice('aioseo_import_invalid');
        }

        $importTemplates = !empty($_POST['import_templates']);
        $importSeparator = !empty($_POST['import_separator']);
        $importSchema = !empty($_POST['import_schema']);
        $importSocial = !empty($_POST['import_social']);
        $importBreadcrumbs = !empty($_POST['import_breadcrumbs']);
        $overwrite = !empty($_POST['overwrite']);

        $reportId = wp_generate_uuid4();
        $report = [
            'applied' => [],
            'skipped' => [],
            'warnings' => [],
        ];

        $current = $this->settingsRepo->getAll();
        $defaults = $this->settingsRepo->defaults();

        // Safety: create an automatic snapshot before applying (for one-click rollback).
        $snapshotId = 0;
        try {
            $snapshotId = (int) ConfigSnapshotService::createAutoSnapshot(
                sprintf('Auto backup: before AIOSEO settings import (%s)', (string) $reportId),
                [
                    'reason' => 'aioseo_settings_import',
                ]
            );
        } catch (\Throwable $e) {
            // Never block import.
            $snapshotId = 0;
        }

        $undoRunId = '';
        if ($snapshotId > 0) {
            $undoRunId = UndoRuns::record([
                'type' => 'config_snapshot',
                'label' => 'AIOSEO settings import (file)',
                'actor_user_id' => (int) get_current_user_id(),
                'meta' => [
                    'snapshot_id' => (int) $snapshotId,
                    'context' => 'aioseo_settings_import_file',
                    'report_id' => (string) $reportId,
                ],
            ]);
        }
        $report['snapshot_id'] = (int) $snapshotId;
        $report['undo_run_id'] = (string) $undoRunId;

        // 1) Separator (best-effort)
        if ($importSeparator) {
            $sep = $this->deepFindFirstLikelySeparator($data);
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'separator', $sep, $overwrite, $report);
            } else {
                $report['warnings'][] = 'AIOSEO export: separator not detected.';
            }
        }

        // 2) Templates (best-effort)
        if ($importTemplates) {
            $titleTpl = $this->deepFindFirstStringByKeyAny($data, ['titleFormat', 'titleTemplate', 'metaTitle', 'siteTitle']);
            if ($titleTpl !== '' && $this->looksLikeTemplate($titleTpl)) {
                $converted = $this->aioseoTemplateToWpsn($titleTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.title', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'AIOSEO export: title template not detected.';
            }

            $descTpl = $this->deepFindFirstStringByKeyAny($data, ['descriptionFormat', 'descriptionTemplate', 'metaDescription', 'siteDescription']);
            if ($descTpl !== '' && $this->looksLikeTemplate($descTpl)) {
                $converted = $this->aioseoTemplateToWpsn($descTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.description', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'AIOSEO export: description template not detected.';
            }
        }

        // 3) Schema defaults (best-effort)
        if ($importSchema) {
            $orgName = $this->deepFindFirstStringByKeyAny($data, ['organizationName', 'orgName', 'companyName', 'knowledgeGraphName', 'siteName']);
            $logoUrl = $this->deepFindFirstUrlByKeyAny($data, ['logoUrl', 'organizationLogo', 'companyLogo', 'knowledgeGraphLogo', 'logo']);

            if ($orgName !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_name', $orgName, $overwrite, $report);
            }
            if ($logoUrl !== '' && $this->looksLikeUrl($logoUrl)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_logo_url', $logoUrl, $overwrite, $report);
            }

            // SameAs URLs (best-effort)
            $sameAs = $this->deepCollectUrlsByKeyContains($data, ['social']);
            $sameAs = array_values(array_unique($sameAs));
            if (!empty($sameAs)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_same_as', $sameAs, $overwrite, $report);
            }
        }

        // 4) Social default image (best-effort)
        if ($importSocial) {
            $img = $this->deepFindFirstUrlByKeyAny($data, ['defaultImage', 'defaultImageUrl', 'openGraphImage', 'ogDefaultImage', 'socialImage', 'facebookImage', 'twitterImage']);
            if ($img === '') {
                $img = $this->deepFindFirstUrlByKeyAny($data, ['logoUrl', 'logo']);
            }

            if ($img !== '' && $this->looksLikeUrl($img)) {
                $this->maybeApplySettingScalar($current, $defaults, 'social.default_image_url', $img, $overwrite, $report);
            } else {
                $report['warnings'][] = 'AIOSEO export: default social image not detected.';
            }
        }

        // 5) Breadcrumbs (best-effort)
        if ($importBreadcrumbs) {
            $section = $this->deepFindFirstArrayByKeyContains($data, ['breadcrumb']);
            if ($section === null) {
                $section = $this->deepFindFirstArrayByKeyContains($data, ['breadcrumbs']);
            }

            $enabled = null;
            if (is_array($section)) {
                $enabled = $this->deepFindFirstBoolByKeyAny($section, ['enabled', 'enable', 'active', 'is_enabled', 'isEnabled']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($data, ['breadcrumb', 'enable']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($data, ['breadcrumbs', 'enable']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($data, ['breadcrumbs']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($data, ['breadcrumb']);
            }

            $home = '';
            $sep = '';

            if (is_array($section)) {
                $home = $this->deepFindFirstStringByKeyContains($section, ['home']);
                $sep = $this->deepFindFirstStringByKeyContains($section, ['sep']);
                if ($sep === '') {
                    $sep = $this->deepFindFirstStringByKeyContains($section, ['separator']);
                }
            }

            if ($home === '') {
                $home = $this->deepFindFirstStringByKeyContains($data, ['breadcrumb', 'home']);
                if ($home === '') {
                    $home = $this->deepFindFirstStringByKeyContains($data, ['breadcrumbs', 'home']);
                }
            }

            if ($sep === '') {
                $sep = $this->deepFindFirstStringByKeyContains($data, ['breadcrumb', 'sep']);
                if ($sep === '') {
                    $sep = $this->deepFindFirstStringByKeyContains($data, ['breadcrumb', 'separator']);
                }
                if ($sep === '') {
                    $sep = $this->deepFindFirstStringByKeyContains($data, ['breadcrumbs', 'separator']);
                }
            }

            if ($sep !== '' && strlen($sep) > 16) {
                $sep = substr($sep, 0, 16);
            }

            if ($enabled === null) {
                if ($section !== null || $home !== '' || $sep !== '') {
                    $enabled = true;
                    $report['warnings'][] = 'AIOSEO export: breadcrumbs enabled flag not found; assumed enabled because breadcrumb fields exist.';
                } else {
                    $report['warnings'][] = 'AIOSEO export: breadcrumbs settings not detected.';
                }
            }

            if ($enabled !== null) {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.ui_enabled', (bool) $enabled, $overwrite, $report);
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.schema_enabled', (bool) $enabled, $overwrite, $report);
            }
            if ($home !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.home_label', $home, $overwrite, $report);
            }
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.separator', $sep, $overwrite, $report);
            }
        }

        try {
            $this->settingsRepo->update($current);
        } catch (\Throwable $e) {
            $this->logger->error('AIOSEO settings import failed: ' . $e->getMessage());
            $this->redirectNotice('aioseo_import_failed');
        }

        set_transient('wpsn_aioseo_import_' . $reportId, $report, self::JOB_TTL);
        $this->recordImportAssistSettingsImport('aioseo', $report, 'file');
        $this->redirectNotice('aioseo_imported', ['aioseo_report' => $reportId]);
    }

    /**
     * Import global settings from Yoast options in wp_options (no export file).
     */
    public function handleImportYoastSettingsFromSite(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to do this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_import_yoast_settings_site');

        $wpseo = get_option('wpseo', null);
        $titles = get_option('wpseo_titles', null);
        $social = get_option('wpseo_social', null);

        if ($titles === null || $titles === false) {
            $this->redirectNotice('yoast_import_invalid');
        }

        if (!is_array($wpseo)) {
            $wpseo = [];
        }
        if (!is_array($titles)) {
            $titles = $this->normalizeMaybeSerialized($titles);
        }
        if (!is_array($titles)) {
            $this->redirectNotice('yoast_import_invalid');
        }
        if (!is_array($social)) {
            $social = $this->normalizeMaybeSerialized($social);
        }
        if (!is_array($social)) {
            $social = [];
        }

        $overwrite = !empty($_POST['overwrite']);
        $importTemplates = !empty($_POST['import_templates']);
        $importSeparator = !empty($_POST['import_separator']);
        $importSchema = !empty($_POST['import_schema']);
        $importSocial = !empty($_POST['import_social']);
        $importRobots = !empty($_POST['import_robots']);
        $importLocal = !empty($_POST['import_local']);
        $importIndexNow = !empty($_POST['import_indexnow']);
        $importVerification = !empty($_POST['import_verification']);
        $importBreadcrumbs = !empty($_POST['import_breadcrumbs']);
        $importArchiveControls = !empty($_POST['import_archive_controls']);
        $importCleanup = !empty($_POST['import_cleanup']);
        $importSearchUrls = !empty($_POST['import_search_urls']);
        $importAiReadiness = !empty($_POST['import_ai_readiness']);

        $reportId = function_exists('wp_generate_uuid4') ? wp_generate_uuid4() : uniqid('wpsn_yoast_site_', true);

        $report = [
            'id' => $reportId,
            'imported_at' => gmdate('c'),
            'mode' => 'site',
            'overwrite' => $overwrite,
            'applied' => [],
            'skipped' => [],
            'not_mapped' => [],
            'warnings' => [],
        ];

        $current = $this->settingsRepo->getAll();
        $defaults = $this->settingsRepo->defaults();

        // Safety: create an automatic snapshot before applying (for one-click rollback).
        $snapshotId = 0;
        try {
            $snapshotId = (int) ConfigSnapshotService::createAutoSnapshot(
                sprintf('Auto backup: before Yoast settings import (site) (%s)', (string) $reportId),
                [
                    'reason' => 'yoast_settings_site_import',
                ]
            );
        } catch (\Throwable $e) {
            // Never block import.
            $snapshotId = 0;
        }

        $undoRunId = '';
        if ($snapshotId > 0) {
            $undoRunId = UndoRuns::record([
                'type' => 'config_snapshot',
                'label' => 'Yoast settings import (site)',
                'actor_user_id' => (int) get_current_user_id(),
                'meta' => [
                    'snapshot_id' => (int) $snapshotId,
                    'context' => 'yoast_settings_import_site',
                    'report_id' => (string) $reportId,
                ],
            ]);
        }
        $report['snapshot_id'] = (int) $snapshotId;
        $report['undo_run_id'] = (string) $undoRunId;

        // 1) Separator
        if ($importSeparator) {
            $rawSep = '';
            if (isset($titles['separator'])) {
                $rawSep = is_string($titles['separator']) ? (string) $titles['separator'] : '';
            }
            $sep = $this->yoastSeparatorToChar($rawSep, $report);
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'separator', $sep, $overwrite, $report);
            }
        }

        // 2) Templates
        if ($importTemplates) {
            $titleTpl = isset($titles['title-post']) && is_string($titles['title-post']) ? trim((string) $titles['title-post']) : '';
            if ($titleTpl === '' && isset($titles['title-home-wpseo']) && is_string($titles['title-home-wpseo'])) {
                $titleTpl = trim((string) $titles['title-home-wpseo']);
            }
            if ($titleTpl !== '') {
                $converted = $this->yoastTemplateToWpsn($titleTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.title', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Yoast (site): title template not found.';
            }

            $descTpl = isset($titles['metadesc-post']) && is_string($titles['metadesc-post']) ? trim((string) $titles['metadesc-post']) : '';
            if ($descTpl === '' && isset($titles['metadesc-home-wpseo']) && is_string($titles['metadesc-home-wpseo'])) {
                $descTpl = trim((string) $titles['metadesc-home-wpseo']);
            }
            if ($descTpl !== '') {
                $converted = $this->yoastTemplateToWpsn($descTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.description', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Yoast (site): meta description template is empty (kept WP SEO Next default).';
            }

            $authorTitle = isset($titles['title-author-wpseo']) && is_string($titles['title-author-wpseo']) ? trim((string) $titles['title-author-wpseo']) : '';
            if ($authorTitle !== '') {
                $converted = $this->yoastTemplateToWpsn($authorTitle);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.author_title', $converted, $overwrite, $report);
            }

            $authorDesc = isset($titles['metadesc-author-wpseo']) && is_string($titles['metadesc-author-wpseo']) ? trim((string) $titles['metadesc-author-wpseo']) : '';
            if ($authorDesc !== '') {
                $converted = $this->yoastTemplateToWpsn($authorDesc);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.author_description', $converted, $overwrite, $report);
            }
        }

        // 3) Schema defaults (Organization name/logo)
        if ($importSchema) {
            $who = isset($titles['company_or_person']) && is_string($titles['company_or_person']) ? trim((string) $titles['company_or_person']) : '';
            $orgName = $this->yoastPreferredEntityName($titles, $who);
            $logoUrl = $this->yoastPreferredEntityLogoUrl($titles, $who);

            if ($orgName !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_name', $orgName, $overwrite, $report);
            }
            if ($logoUrl !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_logo_url', $logoUrl, $overwrite, $report);
            }

            $sameAs = $this->collectYoastSameAsUrls($social);
            if (!empty($sameAs)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_same_as', $sameAs, $overwrite, $report);
            }

            $this->applyYoastSchemaParitySettings($current, $defaults, $titles, $overwrite, $report, 'Yoast (site)');
        }

        // 4) Social default image
        if ($importSocial) {
            $who = isset($titles['company_or_person']) && is_string($titles['company_or_person']) ? trim((string) $titles['company_or_person']) : '';
            $img = $this->yoastPreferredDefaultSocialImageUrl($titles, $social, $who);

            if ($img !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'social.default_image_url', $img, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Yoast (site): default OpenGraph image not set.';
            }
        }

        // 4b) Robots defaults (best-effort)
        if ($importRobots) {
            $this->applyYoastRobotsDefaults($current, $defaults, $titles, $overwrite, $report);
        }

        // 4c) Local SEO business fields (best-effort)
        if ($importLocal) {
            $this->applyYoastLocalBusinessDefaults($current, $defaults, $titles, $overwrite, $report);
        }

        $this->appendYoastPortabilityWarnings($titles, $importRobots, $importSchema, $importLocal, $report, 'Yoast (site)', $importArchiveControls);

        // 5) IndexNow key
        if ($importIndexNow) {
            $yoastEnabled = !empty($wpseo['enable_index_now']);
            $yoastKey = isset($wpseo['index_now_key']) && is_string($wpseo['index_now_key']) ? trim((string) $wpseo['index_now_key']) : '';

            $secrets = new IndexNowSecretsRepository();
            $hasKey = $secrets->hasKey();

            if ($yoastKey !== '') {
                if ($overwrite || !$hasKey) {
                    $secrets->setKey($yoastKey);
                    $report['applied'][] = 'indexnow.key (stored)';
                    $hasKey = $secrets->hasKey();
                } else {
                    $report['skipped'][] = 'indexnow.key (kept existing)';
                }
            } elseif ($yoastEnabled) {
                $report['warnings'][] = 'Yoast (site): IndexNow enabled, but key is empty. Generate a key in WP SEO Next → IndexNow.';
            }

            if ($yoastEnabled && $hasKey) {
                $this->maybeApplySettingScalar($current, $defaults, 'indexnow.enabled', true, true, $report);
            }
        }

        // 5b) Site verification meta tags
        if ($importVerification) {
            $map = [
                'googleverify' => 'verification.google',
                'msverify' => 'verification.bing',
                'yandexverify' => 'verification.yandex',
                'baiduverify' => 'verification.baidu',
                'ahrefsverify' => 'verification.ahrefs',
            ];

            foreach ($map as $src => $dst) {
                if (!array_key_exists($src, $wpseo)) {
                    continue;
                }
                $val = is_string($wpseo[$src]) ? trim((string) $wpseo[$src]) : trim((string) ($wpseo[$src] ?? ''));
                $this->maybeApplySettingScalar($current, $defaults, $dst, $val, $overwrite, $report);
            }
        }

        // 6) Breadcrumbs
        if ($importBreadcrumbs) {
            $this->applyYoastBreadcrumbSettings($current, $defaults, $titles, $overwrite, $report);
        }

        // 6b) Archive controls (best-effort)
        if ($importArchiveControls) {
            $this->applyYoastArchiveControls($current, $defaults, $wpseo, $titles, $overwrite, $report, 'Yoast (site)');
        }

        // 6c) Head cleanup / hardening (best-effort)
        if ($importCleanup) {
            $this->applyYoastHeadCleanupSettings($current, $defaults, $wpseo, $overwrite, $report, 'Yoast (site)');
        }

        // 6d) Search URL canonicalization / search crawl cleanup (best-effort)
        if ($importSearchUrls) {
            $this->applyYoastSearchUrlSettings($current, $defaults, array_merge($wpseo, $titles), $overwrite, $report, 'Yoast (site)');
        }

        // 7) AI Search Readiness (best-effort)
        if ($importAiReadiness) {
            if (array_key_exists('enable_llms_txt', $wpseo)) {
                $this->maybeApplySettingScalar($current, $defaults, 'ai_readiness.llms.enabled', !empty($wpseo['enable_llms_txt']), $overwrite, $report);
            }

            if (array_key_exists('deny_gptbot_crawling', $wpseo)) {
                $deny = !empty($wpseo['deny_gptbot_crawling']);
                $this->maybeApplySettingScalar($current, $defaults, 'ai_readiness.bots.allow.gptbot', !$deny, $overwrite, $report);
            }
            if (array_key_exists('deny_google_extended_crawling', $wpseo)) {
                $deny = !empty($wpseo['deny_google_extended_crawling']);
                $this->maybeApplySettingScalar($current, $defaults, 'ai_readiness.bots.allow.google_extended', !$deny, $overwrite, $report);
            }
        }

        try {
            $this->settingsRepo->update($current);
        } catch (\Throwable $e) {
            $this->logger->error('Yoast settings site import failed: ' . $e->getMessage());
            $this->redirectNotice('yoast_import_failed');
        }

        set_transient('wpsn_yoast_import_' . $reportId, $report, self::JOB_TTL);
        $this->recordImportAssistSettingsImport('yoast', $report, 'site');
        $this->redirectNotice('yoast_imported', ['yoast_report' => $reportId]);
    }

    /**
     * Import global settings from Rank Math options in wp_options (no export file).
     */
    public function handleImportRankMathSettingsFromSite(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_import_rankmath_settings_site');

        $titles = get_option('rank-math-options-titles', null);
        if ($titles === null || $titles === false) {
            $this->redirectNotice('rankmath_import_invalid');
        }

        if (!is_array($titles)) {
            $titles = $this->normalizeMaybeSerialized($titles);
        }
        if (!is_array($titles)) {
            $this->redirectNotice('rankmath_import_invalid');
        }

        $general = get_option('rank-math-options-general', null);
        if (!is_array($general)) {
            $general = $this->normalizeMaybeSerialized($general);
        }
        if (!is_array($general)) {
            $general = [];
        }

        $importTemplates = !empty($_POST['import_templates']);
        $importSeparator = !empty($_POST['import_separator']);
        $importSchema = !empty($_POST['import_schema']);
        $importSocial = !empty($_POST['import_social']);
        $importBreadcrumbs = !empty($_POST['import_breadcrumbs']);
        $overwrite = !empty($_POST['overwrite']);

        $reportId = function_exists('wp_generate_uuid4') ? wp_generate_uuid4() : uniqid('wpsn_rankmath_site_', true);
        $report = [
            'mode' => 'site',
            'applied' => [],
            'skipped' => [],
            'warnings' => [],
        ];

        $current = $this->settingsRepo->getAll();
        $defaults = $this->settingsRepo->defaults();

        $snapshotId = 0;
        try {
            $snapshotId = (int) ConfigSnapshotService::createAutoSnapshot(
                sprintf('Auto backup: before Rank Math settings import (site) (%s)', (string) $reportId),
                [
                    'reason' => 'rankmath_settings_site_import',
                ]
            );
        } catch (\Throwable $e) {
            // Never block import.
            $snapshotId = 0;
        }

        $undoRunId = '';
        if ($snapshotId > 0) {
            $undoRunId = UndoRuns::record([
                'type' => 'config_snapshot',
                'label' => 'Rank Math settings import (site)',
                'actor_user_id' => (int) get_current_user_id(),
                'meta' => [
                    'snapshot_id' => (int) $snapshotId,
                    'context' => 'rankmath_settings_import_site',
                    'report_id' => (string) $reportId,
                ],
            ]);
        }
        $report['snapshot_id'] = (int) $snapshotId;
        $report['undo_run_id'] = (string) $undoRunId;

        $t = $titles;

        // 1) Separator
        if ($importSeparator) {
            $sep = isset($t['title_separator']) && is_string($t['title_separator']) ? trim((string) $t['title_separator']) : '';
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'separator', $sep, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Rank Math (site): separator not found.';
            }
        }

        // 2) Templates
        if ($importTemplates) {
            $titleTpl = isset($t['pt_post_title']) && is_string($t['pt_post_title']) ? trim((string) $t['pt_post_title']) : '';
            if ($titleTpl === '' && isset($t['homepage_title']) && is_string($t['homepage_title'])) {
                $titleTpl = trim((string) $t['homepage_title']);
            }
            if ($titleTpl !== '') {
                $converted = $this->rankMathTemplateToWpsn($titleTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.title', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Rank Math (site): title template not found.';
            }

            $descTpl = isset($t['pt_post_description']) && is_string($t['pt_post_description']) ? trim((string) $t['pt_post_description']) : '';
            if ($descTpl === '' && isset($t['homepage_description']) && is_string($t['homepage_description'])) {
                $descTpl = trim((string) $t['homepage_description']);
            }
            if ($descTpl !== '') {
                $converted = $this->rankMathTemplateToWpsn($descTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.description', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Rank Math (site): description template not found.';
            }

            $authorTitle = isset($t['author_archive_title']) && is_string($t['author_archive_title']) ? trim((string) $t['author_archive_title']) : '';
            if ($authorTitle !== '') {
                $converted = $this->rankMathTemplateToWpsn($authorTitle);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.author_title', $converted, $overwrite, $report);
            }
        }

        // 3) Schema defaults (Organization name/logo + SameAs)
        if ($importSchema) {
            $orgName = isset($t['knowledgegraph_name']) && is_string($t['knowledgegraph_name']) ? trim((string) $t['knowledgegraph_name']) : '';
            $logoUrl = isset($t['knowledgegraph_logo']) && is_string($t['knowledgegraph_logo']) ? trim((string) $t['knowledgegraph_logo']) : '';

            if ($orgName !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_name', $orgName, $overwrite, $report);
            }
            if ($logoUrl !== '' && $this->looksLikeUrl($logoUrl)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_logo_url', $logoUrl, $overwrite, $report);
            }

            $sameAs = [];
            foreach ($t as $k => $v) {
                if (!is_string($k) || !is_string($v)) {
                    continue;
                }
                $kk = strtolower((string) $k);
                if (strpos($kk, 'social_url_') === 0) {
                    $u = trim((string) $v);
                    if ($u !== '' && $this->looksLikeUrl($u)) {
                        $sameAs[] = $u;
                    }
                }
            }
            $sameAs = array_values(array_unique($sameAs));
            if (!empty($sameAs)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_same_as', $sameAs, $overwrite, $report);
            }
        }

        // 4) Social default image
        if ($importSocial) {
            $img = '';
            if (isset($t['open_graph_image']) && is_string($t['open_graph_image'])) {
                $img = trim((string) $t['open_graph_image']);
            }
            if ($img === '' && isset($t['knowledgegraph_logo']) && is_string($t['knowledgegraph_logo'])) {
                $img = trim((string) $t['knowledgegraph_logo']);
            }

            if ($img !== '' && $this->looksLikeUrl($img)) {
                $this->maybeApplySettingScalar($current, $defaults, 'social.default_image_url', $img, $overwrite, $report);
            } else {
                $report['warnings'][] = 'Rank Math (site): default social image not found.';
            }
        }

        // 5) Breadcrumbs (best-effort)
        if ($importBreadcrumbs) {
            $payload = [
                'titles' => $t,
                'general' => is_array($general) ? $general : [],
            ];

            $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumb', 'enable']);
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumbs', 'enable']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumbs']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumb']);
            }

            $home = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'home']);
            if ($home === '') {
                $home = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumbs', 'home']);
            }

            $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'sep']);
            if ($sep === '') {
                $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'separator']);
            }
            if ($sep === '') {
                $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumbs', 'separator']);
            }
            if ($sep !== '' && strlen($sep) > 16) {
                $sep = substr($sep, 0, 16);
            }

            if ($enabled === null) {
                if ($home !== '' || $sep !== '') {
                    $enabled = true;
                    $report['warnings'][] = 'Rank Math (site): breadcrumbs enabled flag not found; assumed enabled because breadcrumb fields exist.';
                } else {
                    $report['warnings'][] = 'Rank Math (site): breadcrumbs settings not found.';
                }
            }

            if ($enabled !== null) {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.ui_enabled', (bool) $enabled, $overwrite, $report);
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.schema_enabled', (bool) $enabled, $overwrite, $report);
            }
            if ($home !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.home_label', $home, $overwrite, $report);
            }
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.separator', $sep, $overwrite, $report);
            }
        }

        try {
            $this->settingsRepo->update($current);
        } catch (\Throwable $e) {
            $this->logger->error('Rank Math settings site import failed: ' . $e->getMessage());
            $this->redirectNotice('rankmath_import_failed');
        }

        set_transient('wpsn_rankmath_import_' . $reportId, $report, self::JOB_TTL);
        $this->recordImportAssistSettingsImport('rankmath', $report, 'site');
        $this->redirectNotice('rankmath_imported', ['rankmath_report' => $reportId]);
    }

    /**
     * Import global settings from SEOPress options in wp_options (no export file).
     */
    public function handleImportSeopressSettingsFromSite(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_import_seopress_settings_site');

        $rawTitles = get_option('seopress_titles_option_name', null);
        $rawSocial = get_option('seopress_social_option_name', null);
        $rawPro = get_option('seopress_pro_option_name', null);

        if ($rawTitles === null && $rawSocial === null && $rawPro === null) {
            $this->redirectNotice('seopress_import_invalid');
        }

        $importTemplates = !empty($_POST['import_templates']);
        $importSeparator = !empty($_POST['import_separator']);
        $importSchema = !empty($_POST['import_schema']);
        $importSocial = !empty($_POST['import_social']);
        $importBreadcrumbs = !empty($_POST['import_breadcrumbs']);
        $overwrite = !empty($_POST['overwrite']);

        $reportId = function_exists('wp_generate_uuid4') ? wp_generate_uuid4() : uniqid('wpsn_seopress_site_', true);
        $report = [
            'mode' => 'site',
            'applied' => [],
            'skipped' => [],
            'warnings' => [],
        ];

        $current = $this->settingsRepo->getAll();
        $defaults = $this->settingsRepo->defaults();

        $snapshotId = 0;
        try {
            $snapshotId = (int) ConfigSnapshotService::createAutoSnapshot(
                sprintf('Auto backup: before SEOPress settings import (site) (%s)', (string) $reportId),
                [
                    'reason' => 'seopress_settings_site_import',
                ]
            );
        } catch (\Throwable $e) {
            // Never block import.
            $snapshotId = 0;
        }

        $undoRunId = '';
        if ($snapshotId > 0) {
            $undoRunId = UndoRuns::record([
                'type' => 'config_snapshot',
                'label' => 'SEOPress settings import (site)',
                'actor_user_id' => (int) get_current_user_id(),
                'meta' => [
                    'snapshot_id' => (int) $snapshotId,
                    'context' => 'seopress_settings_import_site',
                    'report_id' => (string) $reportId,
                ],
            ]);
        }
        $report['snapshot_id'] = (int) $snapshotId;
        $report['undo_run_id'] = (string) $undoRunId;

        $titles = $this->normalizeMaybeSerialized($rawTitles ?? []);
        $social = $this->normalizeMaybeSerialized($rawSocial ?? []);
        $pro = $this->normalizeMaybeSerialized($rawPro ?? []);
        if (!is_array($pro)) {
            $pro = [];
        }

        // 1) Separator
        if ($importSeparator) {
            $sep = isset($titles['seopress_titles_sep']) && is_string($titles['seopress_titles_sep']) ? trim((string) $titles['seopress_titles_sep']) : '';
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'separator', $sep, $overwrite, $report);
            } else {
                $report['warnings'][] = 'SEOPress (site): separator not found.';
            }
        }

        // 2) Templates
        if ($importTemplates) {
            $titleTpl = isset($titles['seopress_titles_title_template']) && is_string($titles['seopress_titles_title_template']) ? trim((string) $titles['seopress_titles_title_template']) : '';
            if ($titleTpl !== '') {
                $converted = $this->seopressTemplateToWpsn($titleTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.title', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'SEOPress (site): title template not found.';
            }

            $descTpl = isset($titles['seopress_titles_desc_template']) && is_string($titles['seopress_titles_desc_template']) ? trim((string) $titles['seopress_titles_desc_template']) : '';
            if ($descTpl !== '') {
                $converted = $this->seopressTemplateToWpsn($descTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.description', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'SEOPress (site): description template not found.';
            }

            $authorTitle = isset($titles['seopress_titles_archives_author_title']) && is_string($titles['seopress_titles_archives_author_title']) ? trim((string) $titles['seopress_titles_archives_author_title']) : '';
            if ($authorTitle !== '') {
                $converted = $this->seopressTemplateToWpsn($authorTitle);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.author_title', $converted, $overwrite, $report);
            }

            $authorDesc = isset($titles['seopress_titles_archives_author_desc']) && is_string($titles['seopress_titles_archives_author_desc']) ? trim((string) $titles['seopress_titles_archives_author_desc']) : '';
            if ($authorDesc !== '') {
                $converted = $this->seopressTemplateToWpsn($authorDesc);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.author_description', $converted, $overwrite, $report);
            }
        }

        // 3) Schema defaults (best-effort)
        if ($importSchema) {
            $orgName = '';
            if (isset($titles['seopress_social_knowledge_name']) && is_string($titles['seopress_social_knowledge_name'])) {
                $orgName = trim((string) $titles['seopress_social_knowledge_name']);
            }
            if ($orgName === '' && isset($social['seopress_social_knowledge_name']) && is_string($social['seopress_social_knowledge_name'])) {
                $orgName = trim((string) $social['seopress_social_knowledge_name']);
            }

            $logoUrl = '';
            if (isset($titles['seopress_social_knowledge_img']) && is_string($titles['seopress_social_knowledge_img'])) {
                $logoUrl = trim((string) $titles['seopress_social_knowledge_img']);
            }
            if ($logoUrl === '' && isset($social['seopress_social_knowledge_img']) && is_string($social['seopress_social_knowledge_img'])) {
                $logoUrl = trim((string) $social['seopress_social_knowledge_img']);
            }

            if ($orgName !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_name', $orgName, $overwrite, $report);
            }
            if ($logoUrl !== '' && $this->looksLikeUrl($logoUrl)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_logo_url', $logoUrl, $overwrite, $report);
            }

            // SameAs: best-effort from SEOPress social profile fields.
            $sameAs = [];
            foreach ($social as $k => $v) {
                if (!is_string($k) || !is_string($v)) {
                    continue;
                }
                $kk = strtolower((string) $k);
                if (strpos($kk, 'seopress_social_accounts_') === 0) {
                    $u = trim((string) $v);
                    if ($u !== '' && $this->looksLikeUrl($u)) {
                        $sameAs[] = $u;
                    }
                }
            }
            $sameAs = array_values(array_unique($sameAs));
            if (!empty($sameAs)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_same_as', $sameAs, $overwrite, $report);
            }
        }

        // 4) Social default image
        if ($importSocial) {
            $img = '';
            if (isset($social['seopress_social_fb_img']) && is_string($social['seopress_social_fb_img'])) {
                $img = trim((string) $social['seopress_social_fb_img']);
            }
            if ($img === '' && isset($social['seopress_social_og_img']) && is_string($social['seopress_social_og_img'])) {
                $img = trim((string) $social['seopress_social_og_img']);
            }
            if ($img === '' && isset($titles['seopress_social_knowledge_img']) && is_string($titles['seopress_social_knowledge_img'])) {
                $img = trim((string) $titles['seopress_social_knowledge_img']);
            }

            if ($img !== '' && $this->looksLikeUrl($img)) {
                $this->maybeApplySettingScalar($current, $defaults, 'social.default_image_url', $img, $overwrite, $report);
            } else {
                $report['warnings'][] = 'SEOPress (site): default social image not found.';
            }
        }

        // 5) Breadcrumbs (best-effort)
        if ($importBreadcrumbs) {
            $payload = [
                'titles' => is_array($titles) ? $titles : [],
                'social' => is_array($social) ? $social : [],
                'pro' => is_array($pro) ? $pro : [],
            ];

            $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumb', 'enable']);
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumbs', 'enable']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumbs']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumb']);
            }

            $home = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'home']);
            if ($home === '') {
                $home = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumbs', 'home']);
            }

            $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'sep']);
            if ($sep === '') {
                $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'separator']);
            }
            if ($sep === '') {
                $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumbs', 'separator']);
            }
            if ($sep !== '' && strlen($sep) > 16) {
                $sep = substr($sep, 0, 16);
            }

            if ($enabled === null) {
                if ($home !== '' || $sep !== '') {
                    $enabled = true;
                    $report['warnings'][] = 'SEOPress (site): breadcrumbs enabled flag not found; assumed enabled because breadcrumb fields exist.';
                } else {
                    $report['warnings'][] = 'SEOPress (site): breadcrumbs settings not detected.';
                }
            }

            if ($enabled !== null) {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.ui_enabled', (bool) $enabled, $overwrite, $report);
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.schema_enabled', (bool) $enabled, $overwrite, $report);
            }
            if ($home !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.home_label', $home, $overwrite, $report);
            }
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.separator', $sep, $overwrite, $report);
            }
        }

        try {
            $this->settingsRepo->update($current);
        } catch (\Throwable $e) {
            $this->logger->error('SEOPress settings site import failed: ' . $e->getMessage());
            $this->redirectNotice('seopress_import_failed');
        }

        set_transient('wpsn_seopress_import_' . $reportId, $report, self::JOB_TTL);
        $this->recordImportAssistSettingsImport('seopress', $report, 'site');
        $this->redirectNotice('seopress_imported', ['seopress_report' => $reportId]);
    }

    /**
     * Import global settings from AIOSEO options in wp_options (no export file).
     */
    public function handleImportAioseoSettingsFromSite(): void
    {
        if (!current_user_can(Capabilities::CAP_TOOLS)) {
            wp_die(esc_html__('You do not have permission to perform this action.', 'wp-seo-next'));
        }

        check_admin_referer('wpsn_import_aioseo_settings_site');

        $main = get_option('aioseo_options', null);
        $dyn  = get_option('aioseo_options_dynamic', null);
        $legacy = get_option('aioseop_options', null);

        $payload = [];
        $mainArr = $this->normalizeOptionDeepArray($main);
        $dynArr = $this->normalizeOptionDeepArray($dyn);
        $legacyArr = $this->normalizeOptionDeepArray($legacy);

        if (is_array($mainArr) && !empty($mainArr)) {
            $payload['aioseo_options'] = $mainArr;
        }
        if (is_array($dynArr) && !empty($dynArr)) {
            $payload['aioseo_options_dynamic'] = $dynArr;
        }
        if (empty($payload) && is_array($legacyArr) && !empty($legacyArr)) {
            // Legacy All in One SEO Pack.
            $payload = $legacyArr;
        } elseif (is_array($legacyArr) && !empty($legacyArr)) {
            $payload['aioseop_options'] = $legacyArr;
        }

        if (!is_array($payload) || empty($payload)) {
            $this->redirectNotice('aioseo_import_invalid');
        }

        $importTemplates = !empty($_POST['import_templates']);
        $importSeparator = !empty($_POST['import_separator']);
        $importSchema = !empty($_POST['import_schema']);
        $importSocial = !empty($_POST['import_social']);
        $importBreadcrumbs = !empty($_POST['import_breadcrumbs']);
        $overwrite = !empty($_POST['overwrite']);

        $reportId = function_exists('wp_generate_uuid4') ? wp_generate_uuid4() : uniqid('wpsn_aioseo_site_', true);
        $report = [
            'mode' => 'site',
            'applied' => [],
            'skipped' => [],
            'warnings' => [],
        ];

        $current = $this->settingsRepo->getAll();
        $defaults = $this->settingsRepo->defaults();

        $snapshotId = 0;
        try {
            $snapshotId = (int) ConfigSnapshotService::createAutoSnapshot(
                sprintf('Auto backup: before AIOSEO settings import (site) (%s)', (string) $reportId),
                [
                    'reason' => 'aioseo_settings_site_import',
                ]
            );
        } catch (\Throwable $e) {
            // Never block import.
            $snapshotId = 0;
        }

        $undoRunId = '';
        if ($snapshotId > 0) {
            $undoRunId = UndoRuns::record([
                'type' => 'config_snapshot',
                'label' => 'AIOSEO settings import (site)',
                'actor_user_id' => (int) get_current_user_id(),
                'meta' => [
                    'snapshot_id' => (int) $snapshotId,
                    'context' => 'aioseo_settings_import_site',
                    'report_id' => (string) $reportId,
                ],
            ]);
        }
        $report['snapshot_id'] = (int) $snapshotId;
        $report['undo_run_id'] = (string) $undoRunId;

        // 1) Separator (best-effort)
        if ($importSeparator) {
            $sep = $this->deepFindFirstLikelySeparator($payload);
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'separator', $sep, $overwrite, $report);
            } else {
                $report['warnings'][] = 'AIOSEO (site): separator not detected.';
            }
        }

        // 2) Templates (best-effort)
        if ($importTemplates) {
            $titleTpl = $this->deepFindFirstStringByKeyAny($payload, ['titleFormat', 'titleTemplate', 'metaTitle', 'siteTitle', 'aiosp_title_format']);
            if ($titleTpl !== '' && $this->looksLikeTemplate($titleTpl)) {
                $converted = $this->aioseoTemplateToWpsn($titleTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.title', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'AIOSEO (site): title template not detected.';
            }

            $descTpl = $this->deepFindFirstStringByKeyAny($payload, ['descriptionFormat', 'descriptionTemplate', 'metaDescription', 'siteDescription', 'aiosp_description_format']);
            if ($descTpl !== '' && $this->looksLikeTemplate($descTpl)) {
                $converted = $this->aioseoTemplateToWpsn($descTpl);
                $this->maybeApplySettingScalar($current, $defaults, 'templates.description', $converted, $overwrite, $report);
            } else {
                $report['warnings'][] = 'AIOSEO (site): description template not detected.';
            }
        }

        // 3) Schema defaults (best-effort)
        if ($importSchema) {
            $orgName = $this->deepFindFirstStringByKeyAny($payload, ['organizationName', 'orgName', 'companyName', 'knowledgeGraphName', 'siteName', 'aiosp_kng_name']);
            $logoUrl = $this->deepFindFirstUrlByKeyAny($payload, ['logoUrl', 'organizationLogo', 'companyLogo', 'knowledgeGraphLogo', 'logo', 'aiosp_kng_logo']);

            if ($orgName !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_name', $orgName, $overwrite, $report);
            }
            if ($logoUrl !== '' && $this->looksLikeUrl($logoUrl)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_logo_url', $logoUrl, $overwrite, $report);
            }

            $sameAs = $this->deepCollectUrlsByKeyContains($payload, ['social']);
            $sameAs = array_values(array_unique($sameAs));
            if (!empty($sameAs)) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_same_as', $sameAs, $overwrite, $report);
            }
        }

        // 4) Social default image (best-effort)
        if ($importSocial) {
            $img = $this->deepFindFirstUrlByKeyAny($payload, ['defaultImage', 'defaultImageUrl', 'openGraphImage', 'ogDefaultImage', 'socialImage', 'facebookImage', 'twitterImage']);
            if ($img === '') {
                $img = $this->deepFindFirstUrlByKeyAny($payload, ['logoUrl', 'logo', 'aiosp_kng_logo']);
            }

            if ($img !== '' && $this->looksLikeUrl($img)) {
                $this->maybeApplySettingScalar($current, $defaults, 'social.default_image_url', $img, $overwrite, $report);
            } else {
                $report['warnings'][] = 'AIOSEO (site): default social image not detected.';
            }
        }

        // 5) Breadcrumbs (best-effort)
        if ($importBreadcrumbs) {
            $section = $this->deepFindFirstArrayByKeyContains($payload, ['breadcrumb']);
            if ($section === null) {
                $section = $this->deepFindFirstArrayByKeyContains($payload, ['breadcrumbs']);
            }

            $enabled = null;
            if (is_array($section)) {
                $enabled = $this->deepFindFirstBoolByKeyAny($section, ['enabled', 'enable', 'active', 'is_enabled', 'isEnabled']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumb', 'enable']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumbs', 'enable']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumbs']);
            }
            if ($enabled === null) {
                $enabled = $this->deepFindFirstBoolByKeyContains($payload, ['breadcrumb']);
            }

            $home = '';
            $sep = '';

            if (is_array($section)) {
                $home = $this->deepFindFirstStringByKeyContains($section, ['home']);
                $sep = $this->deepFindFirstStringByKeyContains($section, ['sep']);
                if ($sep === '') {
                    $sep = $this->deepFindFirstStringByKeyContains($section, ['separator']);
                }
            }

            if ($home === '') {
                $home = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'home']);
                if ($home === '') {
                    $home = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumbs', 'home']);
                }
            }

            if ($sep === '') {
                $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'sep']);
                if ($sep === '') {
                    $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumb', 'separator']);
                }
                if ($sep === '') {
                    $sep = $this->deepFindFirstStringByKeyContains($payload, ['breadcrumbs', 'separator']);
                }
            }

            if ($sep !== '' && strlen($sep) > 16) {
                $sep = substr($sep, 0, 16);
            }

            if ($enabled === null) {
                if ($section !== null || $home !== '' || $sep !== '') {
                    $enabled = true;
                    $report['warnings'][] = 'AIOSEO (site): breadcrumbs enabled flag not found; assumed enabled because breadcrumb fields exist.';
                } else {
                    $report['warnings'][] = 'AIOSEO (site): breadcrumbs settings not detected.';
                }
            }

            if ($enabled !== null) {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.ui_enabled', (bool) $enabled, $overwrite, $report);
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.schema_enabled', (bool) $enabled, $overwrite, $report);
            }
            if ($home !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.home_label', $home, $overwrite, $report);
            }
            if ($sep !== '') {
                $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.separator', $sep, $overwrite, $report);
            }
        }

        try {
            $this->settingsRepo->update($current);
        } catch (\Throwable $e) {
            $this->logger->error('AIOSEO settings site import failed: ' . $e->getMessage());
            $this->redirectNotice('aioseo_import_failed');
        }

        set_transient('wpsn_aioseo_import_' . $reportId, $report, self::JOB_TTL);
        $this->recordImportAssistSettingsImport('aioseo', $report, 'site');
        $this->redirectNotice('aioseo_imported', ['aioseo_report' => $reportId]);
    }

    /**
     * Normalize wp_options payload into an array for deep scanning.
     *
     * @param mixed $value
     * @return array<string,mixed>|null
     */
    private function normalizeOptionDeepArray($value): ?array
    {
        if (is_array($value)) {
            return $value;
        }

        if (is_string($value)) {
            $trim = trim($value);
            if ($trim === '') {
                return null;
            }

            // JSON stored as a string.
            if ((strpos($trim, '{') === 0) || (strpos($trim, '[') === 0)) {
                $decoded = json_decode($trim, true);
                if (is_array($decoded)) {
                    return $decoded;
                }
            }

            $maybe = maybe_unserialize($value);
            if (is_array($maybe)) {
                return $maybe;
            }
        }

        return null;
    }

    /**
     * Best-effort parser for Yoast export files.
     *
     * Some users may accidentally paste extra text below the exported INI.
     * We try to salvage the INI by filtering out obvious non-INI lines.
     *
     * @return array<string,mixed>|null
     */
    private function parseYoastIniBestEffort(string $contents, int &$ignoredLines = 0): ?array
    {
        $ignoredLines = 0;
        if (strncmp($contents, "\xEF\xBB\xBF", 3) === 0) {
            $contents = (string) substr($contents, 3);
        }
        $contents = (string) str_replace(["\r\n", "\r"], "\n", $contents);

        $ini = @parse_ini_string($contents, true, INI_SCANNER_RAW);
        if (is_array($ini)) {
            return $ini;
        }

        $lines = preg_split('/\n/', $contents);
        if (!is_array($lines)) {
            return null;
        }

        $kept = [];
        foreach ($lines as $line) {
            $raw = (string) $line;
            $trim = trim($raw);

            if ($trim === '') {
                $kept[] = '';
                continue;
            }

            // Standard INI comment from Yoast export header. Ignore without counting.
            $first = $trim[0] ?? '';
            if ($first === ';') {
                continue;
            }

            // Markdown / pasted headings / non-INI comments.
            if ($first === '#') {
                $ignoredLines++;
                continue;
            }

            // [section]
            if (preg_match('/^\[[A-Za-z0-9_]+\]\s*$/', $trim)) {
                $kept[] = $trim;
                continue;
            }

            // key = value
            if (preg_match('/^[A-Za-z0-9_\-]+(?:\[\])?\s*=.*$/', $trim)) {
                $kept[] = $raw;
                continue;
            }

            $ignoredLines++;
        }

        $filtered = implode("\n", $kept);
        $ini = @parse_ini_string($filtered, true, INI_SCANNER_RAW);
        if (is_array($ini)) {
            return $ini;
        }

        return null;
    }

    /**
     * Collect best-effort SameAs profile URLs from Yoast social settings.
     *
     * @param array<string,mixed> $social
     * @return array<int,string>
     */
    private function collectYoastSameAsUrls(array $social): array
    {
        if (empty($social)) {
            return [];
        }

        $sameAs = [];
        foreach ($social as $k => $v) {
            if (!is_string($k) || !is_string($v)) {
                continue;
            }

            $kk = strtolower(trim($k));
            $u = trim($v);

            if ($u === '' || !$this->looksLikeUrl($u)) {
                continue;
            }

            // Skip obvious image/logo settings.
            if (
                strpos($kk, 'image') !== false
                || strpos($kk, 'logo') !== false
                || strpos($kk, 'avatar') !== false
                || strpos($kk, 'picture') !== false
            ) {
                continue;
            }

            $looksLikeProfileKey = (
                $kk === 'facebook_site'
                || strpos($kk, '_url') !== false
                || strpos($kk, 'facebook') !== false
                || strpos($kk, 'instagram') !== false
                || strpos($kk, 'linkedin') !== false
                || strpos($kk, 'myspace') !== false
                || strpos($kk, 'youtube') !== false
                || strpos($kk, 'pinterest') !== false
                || strpos($kk, 'wikipedia') !== false
                || strpos($kk, 'mastodon') !== false
                || strpos($kk, 'tiktok') !== false
                || strpos($kk, 'threads') !== false
                || strpos($kk, 'discord') !== false
                || strpos($kk, 'telegram') !== false
            );

            if (!$looksLikeProfileKey) {
                continue;
            }

            $sameAs[] = $u;
        }

        return array_values(array_unique($sameAs));
    }

    /**
     * Import breadcrumbs settings from Yoast titles settings.
     *
     * We only toggle enabled/schema flags when Yoast explicitly exported them,
     * so partial or hand-edited files do not accidentally disable breadcrumbs.
     *
     * @param array<string,mixed> $current
     * @param array<string,mixed> $defaults
     * @param array<string,mixed> $titles
     * @param array<string,mixed> $report
     */
    private function applyYoastBreadcrumbSettings(array &$current, array $defaults, array $titles, bool $overwrite, array &$report): void
    {
        $this->applyYoastSettingFromFlag(
            $current,
            $defaults,
            $titles,
            ['breadcrumbs-enable', 'breadcrumbs_enable'],
            'breadcrumbs.ui_enabled',
            false,
            $overwrite,
            $report
        );

        $this->applyYoastSettingFromFlag(
            $current,
            $defaults,
            $titles,
            ['breadcrumbs-enable', 'breadcrumbs_enable'],
            'breadcrumbs.schema_enabled',
            false,
            $overwrite,
            $report
        );

        $stringMap = [
            'breadcrumbs.home_label' => [
                'keys' => ['breadcrumbs-home', 'breadcrumbs_home'],
                'max' => 80,
            ],
            'breadcrumbs.trail_prefix' => [
                'keys' => ['breadcrumbs-prefix', 'breadcrumbs_prefix'],
                'max' => 120,
            ],
            'breadcrumbs.archive_prefix' => [
                'keys' => ['breadcrumbs-archiveprefix', 'breadcrumbs_archiveprefix'],
                'max' => 120,
            ],
            'breadcrumbs.search_prefix' => [
                'keys' => ['breadcrumbs-searchprefix', 'breadcrumbs_searchprefix'],
                'max' => 120,
            ],
            'breadcrumbs.not_found_label' => [
                'keys' => ['breadcrumbs-404crumb', 'breadcrumbs_404crumb'],
                'max' => 120,
            ],
        ];

        foreach ($stringMap as $path => $cfg) {
            $keys = isset($cfg['keys']) && is_array($cfg['keys']) ? $cfg['keys'] : [];
            $value = $this->yoastGetFirstStringByKeys($titles, $keys);
            if ($value === '') {
                continue;
            }

            $max = isset($cfg['max']) ? (int) $cfg['max'] : 0;
            if ($max > 0 && strlen($value) > $max) {
                $value = substr($value, 0, $max);
            }

            $this->maybeApplySettingScalar($current, $defaults, $path, $value, $overwrite, $report);
        }

        $sep = $this->yoastGetFirstStringByKeys($titles, ['breadcrumbs-sep', 'breadcrumbs_sep']);
        if ($sep !== '') {
            if (strlen($sep) > 16) {
                $sep = substr($sep, 0, 16);
            }
            $this->maybeApplySettingScalar($current, $defaults, 'breadcrumbs.separator', $sep, $overwrite, $report);
        }
    }

    /**
     * Import broad robots defaults from Yoast titles settings.
     *
     * We only map settings when the source flags are consistent enough to fit
     * WP SEO Next's broader context-level robots defaults.
     *
     * @param array<string,mixed> $current
     * @param array<string,mixed> $defaults
     * @param array<string,mixed> $titles
     * @param array<string,mixed> $report
     */
    private function applyYoastRobotsDefaults(array &$current, array $defaults, array $titles, bool $overwrite, array &$report): void
    {
        // Historic Yoast flag for noindexing paginated subpages of archives.
        $this->applyYoastSettingFromFlag(
            $current,
            $defaults,
            $titles,
            ['noindex-subpages-wpseo', 'noindex_subpages_wpseo'],
            'robots.noindex_paged',
            false,
            $overwrite,
            $report
        );

        // More specific contexts that WP SEO Next can represent directly.
        $this->applyYoastSettingFromFlag(
            $current,
            $defaults,
            $titles,
            ['noindex-search-wpseo', 'noindex_search_wpseo'],
            'robots.contexts.search.index',
            true,
            $overwrite,
            $report
        );
        $this->applyYoastSettingFromFlag(
            $current,
            $defaults,
            $titles,
            ['noindex-404-wpseo', 'noindex_404_wpseo'],
            'robots.contexts.404.index',
            true,
            $overwrite,
            $report
        );

        $specificKeys = [
            'noindex-subpages-wpseo' => true,
            'noindex_subpages_wpseo' => true,
            'noindex-search-wpseo' => true,
            'noindex_search_wpseo' => true,
            'noindex-404-wpseo' => true,
            'noindex_404_wpseo' => true,
        ];

        $termFlags = [];
        $singularFlags = [];
        $archiveFlags = [];
        $hasSpecificAuthorFlag = false;
        $authorNoindexFlag = false;
        $attachmentNoindexFlag = false;

        foreach ($titles as $k => $v) {
            if (!is_string($k)) {
                continue;
            }

            $kk = strtolower(trim($k));
            if ($kk === '') {
                continue;
            }

            if (isset($specificKeys[$kk])) {
                continue;
            }

            if (preg_match('/^noindex-tax-[a-z0-9_]+$/', $kk)) {
                $termFlags[$kk] = $this->yoastValueIsEnabled($v);
                continue;
            }

            if ($kk === 'noindex-author-wpseo' || $kk === 'noindex-author-noposts-wpseo') {
                $hasSpecificAuthorFlag = true;
                if ($this->yoastValueIsEnabled($v)) {
                    $authorNoindexFlag = true;
                }
                continue;
            }

            if ($kk === 'noindex-attachment') {
                if ($this->yoastValueIsEnabled($v)) {
                    $attachmentNoindexFlag = true;
                }
                continue;
            }

            if ($kk === 'noindex-archive-wpseo' || preg_match('/^noindex-ptarchive-[a-z0-9_]+$/', $kk)) {
                $archiveFlags[$kk] = $this->yoastValueIsEnabled($v);
                continue;
            }

            if (preg_match('/^noindex-[a-z0-9_]+$/', $kk)) {
                $singularFlags[$kk] = $this->yoastValueIsEnabled($v);
            }
        }

        $this->applyYoastUniformNoindexBucket(
            $current,
            $defaults,
            'robots.contexts.term.index',
            $termFlags,
            $overwrite,
            $report,
            'Yoast export: taxonomy noindex flags are mixed; generic term robots default was not changed.'
        );

        $this->applyYoastUniformNoindexBucket(
            $current,
            $defaults,
            'robots.contexts.singular.index',
            $singularFlags,
            $overwrite,
            $report,
            'Yoast export: content-type noindex flags are mixed; generic singular robots default was not changed.'
        );

        $this->applyYoastUniformNoindexBucket(
            $current,
            $defaults,
            'robots.contexts.archive.index',
            $archiveFlags,
            $overwrite,
            $report,
            'Yoast export: archive noindex flags are mixed; generic archive robots default was not changed.'
        );

        if ($authorNoindexFlag) {
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.author.enabled', true, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.author.mode', ArchiveControlsModule::ARCHIVE_MODE_NOINDEX, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.author.index', false, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.author.follow', true, $overwrite, $report);
        }

        if ($attachmentNoindexFlag) {
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.attachments.mode', 'noindex', $overwrite, $report);
        }

        if ($hasSpecificAuthorFlag && empty($archiveFlags) && !$authorNoindexFlag) {
            $report['warnings'][] = 'Yoast export: author-archive noindex is more specific than WP SEO Next archive defaults and was not mapped automatically.';
        }
    }

    /**
     * Apply a setting from the first matching Yoast flag key.
     *
     * @param array<string,mixed> $current
     * @param array<string,mixed> $defaults
     * @param array<string,mixed> $titles
     * @param array<int,string> $keys
     * @param array<string,mixed> $report
     */
    private function applyYoastSettingFromFlag(array &$current, array $defaults, array $titles, array $keys, string $path, bool $invert, bool $overwrite, array &$report): bool
    {
        $lookup = [];
        foreach ($titles as $k => $v) {
            if (!is_string($k)) {
                continue;
            }
            $lookup[strtolower(trim($k))] = $v;
        }

        foreach ($keys as $key) {
            $kk = strtolower(trim((string) $key));
            if ($kk === '' || !array_key_exists($kk, $lookup)) {
                continue;
            }

            $flag = $this->yoastValueIsEnabled($lookup[$kk]);
            $value = $invert ? !$flag : $flag;
            $this->maybeApplySettingScalar($current, $defaults, $path, $value, $overwrite, $report);
            return true;
        }

        return false;
    }

    /**
     * Apply a bucket of per-type noindex flags only when they are uniform.
     *
     * @param array<string,mixed> $current
     * @param array<string,mixed> $defaults
     * @param array<string,bool>  $flags
     * @param array<string,mixed> $report
     */
    private function applyYoastUniformNoindexBucket(array &$current, array $defaults, string $path, array $flags, bool $overwrite, array &$report, string $mixedWarning): void
    {
        if (empty($flags)) {
            return;
        }

        $unique = array_values(array_unique(array_map(static function ($v): int {
            return $v ? 1 : 0;
        }, $flags)));

        if (count($unique) !== 1) {
            $report['warnings'][] = $mixedWarning;
            return;
        }

        $allNoindex = ($unique[0] === 1);
        $this->maybeApplySettingScalar($current, $defaults, $path, !$allNoindex, $overwrite, $report);
    }

    /**
     * Import Local SEO business defaults from Yoast titles settings.
     *
     * @param array<string,mixed> $current
     * @param array<string,mixed> $defaults
     * @param array<string,mixed> $titles
     * @param array<string,mixed> $report
     */
    private function applyYoastLocalBusinessDefaults(array &$current, array $defaults, array $titles, bool $overwrite, array &$report): void
    {
        $who = isset($titles['company_or_person']) && is_string($titles['company_or_person']) ? strtolower(trim((string) $titles['company_or_person'])) : '';

        $name = $this->yoastPreferredEntityName($titles, $who);

        $fieldMap = [
            'local.business.name' => $name,
            'local.business.telephone' => $this->yoastGetFirstStringByKeys($titles, ['org-phone', 'org_phone', 'org-telephone', 'telephone', 'phone']),
            'local.business.email' => $this->yoastGetFirstStringByKeys($titles, ['org-email', 'org_email', 'email']),
            'local.business.street_address' => $this->yoastGetFirstStringByKeys($titles, ['org-street-address', 'org_street_address', 'street_address', 'street-address', 'org-street']),
            'local.business.address_locality' => $this->yoastGetFirstStringByKeys($titles, ['org-address-locality', 'org_address_locality', 'address_locality', 'address-locality', 'city', 'org-city']),
            'local.business.address_region' => $this->yoastGetFirstStringByKeys($titles, ['org-address-region', 'org_address_region', 'address_region', 'address-region', 'region', 'state', 'province', 'org-state']),
            'local.business.postal_code' => $this->yoastGetFirstStringByKeys($titles, ['org-postal-code', 'org_postal_code', 'postal_code', 'postal-code', 'zip', 'zip_code', 'org-zip']),
            'local.business.address_country' => $this->yoastGetFirstStringByKeys($titles, ['org-address-country', 'org_address_country', 'address_country', 'address-country', 'country', 'country_code', 'org-country']),
            'local.business.price_range' => $this->yoastGetFirstStringByKeys($titles, ['org-price-range', 'org_price_range', 'price_range', 'price-range']),
            'local.business.latitude' => $this->yoastGetFirstStringByKeys($titles, ['org-latitude', 'org_latitude', 'latitude', 'geo_latitude']),
            'local.business.longitude' => $this->yoastGetFirstStringByKeys($titles, ['org-longitude', 'org_longitude', 'longitude', 'geo_longitude', 'lng']),
            'local.business.opening_hours' => $this->yoastGetFirstStringByKeys($titles, ['org-opening-hours', 'org_opening_hours', 'opening_hours', 'opening-hours', 'openinghours']),
        ];

        $foundAny = false;
        foreach ($fieldMap as $path => $value) {
            if ($value === '') {
                continue;
            }
            $foundAny = true;
            $this->maybeApplySettingScalar($current, $defaults, $path, $value, $overwrite, $report);
        }

        if (!$foundAny) {
            $report['warnings'][] = 'Yoast export: no Local SEO business fields were found to import.';
        }
    }

    /**
     * Import Yoast schema-adjacent settings that now have direct targets in WP SEO Next.
     *
     * @param array<string,mixed> $current
     * @param array<string,mixed> $defaults
     * @param array<string,mixed> $titles
     * @param array<string,mixed> $report
     */
    private function applyYoastSchemaParitySettings(array &$current, array $defaults, array $titles, bool $overwrite, array &$report, string $label = 'Yoast export'): void
    {
        if (empty($titles)) {
            return;
        }

        $fieldMap = [
            'schema.org_alternate_name' => ['org-alternate-name', 'org_alternate_name', 'org-alternate', 'org_alternate'],
            'schema.org_description' => ['org-description', 'org_description'],
            'schema.org_email' => ['org-email', 'org_email'],
            'schema.org_telephone' => ['org-phone', 'org_phone', 'org-telephone', 'org_telephone'],
            'schema.org_legal_name' => ['org-legal-name', 'org_legal_name'],
            'schema.org_founding_date' => ['org-founding-date', 'org_founding_date'],
            'schema.org_vat_id' => ['org-vat-id', 'org_vat_id'],
            'schema.org_tax_id' => ['org-tax-id', 'org_tax_id'],
            'schema.org_iso6523_code' => ['org-iso', 'org_iso', 'org-iso6523-code', 'org_iso6523_code'],
            'schema.org_duns' => ['org-duns', 'org_duns'],
            'schema.org_lei_code' => ['org-leicode', 'org_lei_code', 'org-lei-code'],
            'schema.org_naics' => ['org-naics', 'org_naics'],
        ];

        foreach ($fieldMap as $path => $keys) {
            $value = $this->yoastGetFirstStringByKeys($titles, $keys);
            if ($value === '') {
                continue;
            }
            $this->maybeApplySettingScalar($current, $defaults, $path, $value, $overwrite, $report);
        }

        $employeeCount = $this->yoastGetFirstStringByKeys($titles, ['org-number-employees', 'org_number_employees']);
        if ($employeeCount !== '') {
            $normalizedCount = (int) preg_replace('/[^0-9]/', '', $employeeCount);
            if ($normalizedCount > 0) {
                $this->maybeApplySettingScalar($current, $defaults, 'schema.org_number_employees', $normalizedCount, $overwrite, $report);
            } else {
                $this->appendUniqueReportNotMapped(
                    $report,
                    sprintf('%s: could not normalize organization employee count from “%s”.', $label, $employeeCount)
                );
            }
        }

        $policyMap = [
            'publishing_principles_id' => 'publishing_principles',
            'ownership_funding_info_id' => 'ownership_funding_info',
            'actionable_feedback_policy_id' => 'actionable_feedback_policy',
            'corrections_policy_id' => 'corrections_policy',
            'ethics_policy_id' => 'ethics_policy',
            'diversity_policy_id' => 'diversity_policy',
            'diversity_staffing_report_id' => 'diversity_staffing_report',
        ];

        foreach ($policyMap as $sourceKey => $targetKey) {
            if (!array_key_exists($sourceKey, $titles)) {
                continue;
            }

            $raw = trim($this->firstStringFromUnknownValue($titles[$sourceKey]));
            if ($raw === '' || $raw === '0') {
                continue;
            }

            $url = $this->resolveYoastContentReferenceUrl($titles[$sourceKey]);
            if ($url === '') {
                $this->appendUniqueReportNotMapped(
                    $report,
                    sprintf('%s: could not resolve %s to a public URL.', $label, str_replace('_', ' ', $sourceKey))
                );
                continue;
            }

            $this->maybeApplySettingScalar($current, $defaults, 'schema.policy_urls.' . $targetKey, $url, $overwrite, $report);
        }

        $pageTypes = [];
        $articleTypes = [];
        foreach ($titles as $key => $value) {
            if (!is_string($key)) {
                continue;
            }

            $keyNorm = strtolower(trim($key));
            if (preg_match('/^schema-page-type-(.+)$/', $keyNorm, $match)) {
                $pageTypes[$match[1]] = $this->firstStringFromUnknownValue($value);
                continue;
            }

            if (preg_match('/^schema-article-type-(.+)$/', $keyNorm, $match)) {
                $articleTypes[$match[1]] = $this->firstStringFromUnknownValue($value);
            }
        }

        $suffixes = array_values(array_unique(array_merge(array_keys($pageTypes), array_keys($articleTypes))));
        foreach ($suffixes as $suffix) {
            $pageType = isset($pageTypes[$suffix]) ? (string) $pageTypes[$suffix] : '';
            $articleType = isset($articleTypes[$suffix]) ? (string) $articleTypes[$suffix] : '';
            $normalizedPage = $this->normalizeSchemaToken($pageType);
            $normalizedArticle = $this->normalizeSchemaToken($articleType);
            if ($normalizedPage === '' && $normalizedArticle === '') {
                continue;
            }
            if (in_array($normalizedPage, ['default', '0'], true) && in_array($normalizedArticle, ['', 'default', '0', 'none'], true)) {
                continue;
            }

            $postType = $this->resolveYoastSchemaPostTypeKey((string) $suffix);
            if ($postType === '') {
                $this->appendUniqueReportNotMapped(
                    $report,
                    sprintf('%s: could not map Yoast schema defaults for content type key “%s”.', $label, (string) $suffix)
                );
                continue;
            }

            $schemaType = $this->mapSchemaTypeFromYoast($pageType, $articleType, $postType);
            if ($schemaType === '') {
                continue;
            }

            $this->maybeApplySettingScalar($current, $defaults, 'schema.default_types.' . $postType, $schemaType, $overwrite, $report);
        }
    }

    /**
     * Import archive-related controls from Yoast.
     *
     * Yoast has a mix of "disable archive" and "noindex archive" semantics. WP SEO Next
     * can model both directly, so disable-author / disable-date now map to explicit
     * disable modes and noindex-author maps to a noindex archive rule.
     *
     * @param array<string,mixed> $current
     * @param array<string,mixed> $defaults
     * @param array<string,mixed> $wpseo
     * @param array<string,mixed> $titles
     * @param array<string,mixed> $report
     */
    private function applyYoastArchiveControls(array &$current, array $defaults, array $wpseo, array $titles, bool $overwrite, array &$report, string $label = 'Yoast export'): void
    {
        $lookup = [];
        foreach ($titles as $k => $v) {
            if (is_string($k)) {
                $lookup[strtolower(trim($k))] = $v;
            }
        }

        $authorDisabled = $this->yoastValueIsEnabled($lookup['disable-author'] ?? null);
        $authorNoindex = $this->yoastValueIsEnabled($lookup['noindex-author-wpseo'] ?? null)
            || $this->yoastValueIsEnabled($lookup['noindex-author-noposts-wpseo'] ?? null);
        if ($authorDisabled || $authorNoindex) {
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.author.enabled', true, $overwrite, $report);
            $this->maybeApplySettingScalar(
                $current,
                $defaults,
                'archive_controls.author.mode',
                $authorDisabled ? ArchiveControlsModule::ARCHIVE_MODE_DISABLE_301_TO_HOME : ArchiveControlsModule::ARCHIVE_MODE_NOINDEX,
                $overwrite,
                $report
            );
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.author.index', false, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.author.follow', true, $overwrite, $report);
        }

        $dateDisabled = $this->yoastValueIsEnabled($lookup['disable-date'] ?? null);
        if ($dateDisabled) {
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.date.enabled', true, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.date.mode', ArchiveControlsModule::ARCHIVE_MODE_DISABLE_301_TO_HOME, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.date.index', false, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'archive_controls.date.follow', true, $overwrite, $report);
        }

        $attachmentDisabled = $this->yoastValueIsEnabled($lookup['disable-attachment'] ?? null);
        $attachmentNoindex = $this->yoastValueIsEnabled($lookup['noindex-attachment'] ?? null);
        if ($attachmentDisabled) {
            $this->maybeApplySettingScalar(
                $current,
                $defaults,
                'archive_controls.attachments.mode',
                ArchiveControlsModule::ATTACHMENT_MODE_REDIRECT_PARENT,
                $overwrite,
                $report
            );

            $this->appendUniqueReportWarning(
                $report,
                $label . ': disable-attachment was imported as “redirect to parent, otherwise file” (best-effort). Change it to noindex if you prefer keeping attachment pages accessible.'
            );
        } elseif ($attachmentNoindex) {
            $this->maybeApplySettingScalar(
                $current,
                $defaults,
                'archive_controls.attachments.mode',
                ArchiveControlsModule::ATTACHMENT_MODE_NOINDEX,
                $overwrite,
                $report
            );
        }
    }

    /**
     * Import Yoast cleanup / hardening toggles into Head Cleanup settings.
     *
     * @param array<string,mixed> $current
     * @param array<string,mixed> $defaults
     * @param array<string,mixed> $wpseo
     * @param array<string,mixed> $report
     */
    private function applyYoastHeadCleanupSettings(array &$current, array $defaults, array $wpseo, bool $overwrite, array &$report, string $label = 'Yoast export'): void
    {
        if (empty($wpseo)) {
            return;
        }

        $map = [
            'remove_emoji_scripts' => 'remove_emoji',
            'remove_generator' => 'remove_generator',
            'remove_shortlinks' => 'remove_shortlink',
            'remove_rest_api_links' => 'remove_rest_links',
            'remove_oembed_links' => 'disable_oembed_discovery',
            'remove_pingback_header' => 'remove_pingback_header',
            'remove_powered_by_header' => 'remove_powered_by_header',
        ];

        $foundAny = false;
        $enabled = false;

        foreach ($map as $src => $dst) {
            if (!array_key_exists($src, $wpseo)) {
                continue;
            }

            $foundAny = true;
            $flag = $this->yoastValueIsEnabled($wpseo[$src]);
            if ($flag) {
                $enabled = true;
            }
            $this->maybeApplySettingScalar($current, $defaults, 'head_cleanup.' . $dst, $flag, $overwrite, $report);
        }

        if (array_key_exists('remove_rsd_wlw_links', $wpseo)) {
            $foundAny = true;
            $flag = $this->yoastValueIsEnabled($wpseo['remove_rsd_wlw_links']);
            if ($flag) {
                $enabled = true;
            }
            $this->maybeApplySettingScalar($current, $defaults, 'head_cleanup.remove_rsd', $flag, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'head_cleanup.remove_wlwmanifest', $flag, $overwrite, $report);
        }

        $feedGlobalKeys = [
            'remove_feed_global',
            'remove_feed_authors',
            'remove_feed_categories',
            'remove_feed_tags',
            'remove_feed_custom_taxonomies',
            'remove_feed_post_types',
            'remove_atom_rdf_feeds',
        ];
        $feedCommentsKeys = [
            'remove_feed_global_comments',
            'remove_feed_post_comments',
        ];
        $feedSearchKeys = [
            'remove_feed_search',
        ];
        $feedSeen = false;
        $feedEnabled = false;
        $feedGlobalEnabled = false;
        $feedCommentsEnabled = false;
        $feedSearchEnabled = false;

        foreach ($feedGlobalKeys as $key) {
            if (!array_key_exists($key, $wpseo)) {
                continue;
            }
            $feedSeen = true;
            if ($this->yoastValueIsEnabled($wpseo[$key])) {
                $feedEnabled = true;
                $feedGlobalEnabled = true;
            }
        }

        foreach ($feedCommentsKeys as $key) {
            if (!array_key_exists($key, $wpseo)) {
                continue;
            }
            $feedSeen = true;
            if ($this->yoastValueIsEnabled($wpseo[$key])) {
                $feedEnabled = true;
                $feedCommentsEnabled = true;
            }
        }

        foreach ($feedSearchKeys as $key) {
            if (!array_key_exists($key, $wpseo)) {
                continue;
            }
            $feedSeen = true;
            if ($this->yoastValueIsEnabled($wpseo[$key])) {
                $feedEnabled = true;
                $feedSearchEnabled = true;
            }
        }

        if ($feedSeen) {
            $foundAny = true;
            if ($feedEnabled) {
                $enabled = true;
            }
            $this->maybeApplySettingScalar($current, $defaults, 'head_cleanup.remove_feed_links', $feedEnabled, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'head_cleanup.disable_feed_endpoints_global', $feedGlobalEnabled, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'head_cleanup.disable_feed_endpoints_comments', $feedCommentsEnabled, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'head_cleanup.disable_feed_endpoints_search', $feedSearchEnabled, $overwrite, $report);
        }

        if (!$foundAny) {
            return;
        }

        $this->maybeApplySettingScalar($current, $defaults, 'head_cleanup.preset', HeadCleanupProfile::PRESET_CUSTOM, $overwrite, $report);
        $this->maybeApplySettingScalar($current, $defaults, 'head_cleanup.enabled', $enabled, $overwrite, $report);

        if ($enabled) {
            $this->appendUniqueReportWarning(
                $report,
                $label . ': head cleanup toggles were imported as a custom profile. Review the resulting front-end head output before enabling stricter cleanup on a production site.'
            );
        }
    }

    /**
     * Import search URL canonicalization / crawl cleanup settings from Yoast.
     *
     * @param array<string,mixed> $current
     * @param array<string,mixed> $defaults
     * @param array<string,mixed> $wpseo
     * @param array<string,mixed> $report
     */
    private function applyYoastSearchUrlSettings(array &$current, array $defaults, array $wpseo, bool $overwrite, array &$report, string $label = 'Yoast export'): void
    {
        if (empty($wpseo)) {
            return;
        }

        $searchUrlSeen = false;
        $searchUrlsEnabled = false;
        $mode = SearchUrlsModule::MODE_FORMAT;
        $sanitizeQuerySeen = false;
        $sanitizeQuery = false;
        $stripEmojiSeen = false;
        $stripEmoji = false;
        $blockCommonPatternsSeen = false;
        $blockCommonPatterns = false;
        $queryLimitSeen = false;
        $queryLimit = (int) ($defaults['search_urls']['query_limit'] ?? 50);
        $stripCategoryBaseSeen = false;
        $stripCategoryBase = false;

        if (array_key_exists('redirect_search_pretty_urls', $wpseo)) {
            $searchUrlSeen = true;
            if ($this->yoastValueIsEnabled($wpseo['redirect_search_pretty_urls'])) {
                $searchUrlsEnabled = true;
                $mode = SearchUrlsModule::MODE_FORMAT;
            }
        }

        if (array_key_exists('search_cleanup', $wpseo)) {
            $sanitizeQuerySeen = true;
            $sanitizeQuery = $this->yoastValueIsEnabled($wpseo['search_cleanup']);
        }

        if (array_key_exists('search_cleanup_emoji', $wpseo)) {
            $stripEmojiSeen = true;
            $stripEmoji = $this->yoastValueIsEnabled($wpseo['search_cleanup_emoji']);
        }

        if (array_key_exists('search_cleanup_patterns', $wpseo)) {
            $blockCommonPatternsSeen = true;
            $blockCommonPatterns = $this->yoastValueIsEnabled($wpseo['search_cleanup_patterns']);
        }

        if (array_key_exists('search_character_limit', $wpseo)) {
            $queryLimitSeen = true;
            $queryLimit = max(0, min(200, (int) $wpseo['search_character_limit']));
        }

        if (array_key_exists('stripcategorybase', $wpseo)) {
            $stripCategoryBaseSeen = true;
            $stripCategoryBase = $this->yoastValueIsEnabled($wpseo['stripcategorybase']);
        }

        if ($searchUrlSeen) {
            $this->maybeApplySettingScalar($current, $defaults, 'search_urls.enabled', $searchUrlsEnabled, $overwrite, $report);
            $this->maybeApplySettingScalar($current, $defaults, 'search_urls.mode', $mode, $overwrite, $report);
        }

        if ($sanitizeQuerySeen) {
            $this->maybeApplySettingScalar($current, $defaults, 'search_urls.sanitize_query', $sanitizeQuery, $overwrite, $report);
        }

        if ($stripEmojiSeen) {
            $this->maybeApplySettingScalar($current, $defaults, 'search_urls.strip_emoji', $stripEmoji, $overwrite, $report);
        }

        if ($blockCommonPatternsSeen) {
            $this->maybeApplySettingScalar($current, $defaults, 'search_urls.block_common_patterns', $blockCommonPatterns, $overwrite, $report);
        }

        if ($queryLimitSeen) {
            $this->maybeApplySettingScalar($current, $defaults, 'search_urls.query_limit', $queryLimit, $overwrite, $report);
        }

        if ($stripCategoryBaseSeen) {
            $this->maybeApplySettingScalar($current, $defaults, 'search_urls.strip_category_base', $stripCategoryBase, $overwrite, $report);
        }

        if (array_key_exists('deny_search_crawling', $wpseo)) {
            $deny = $this->yoastValueIsEnabled($wpseo['deny_search_crawling']);
            $this->maybeApplySettingScalar($current, $defaults, 'robots.contexts.search.index', !$deny, $overwrite, $report);
        }

        if (array_key_exists('deny_wp_json_crawling', $wpseo) && $this->yoastValueIsEnabled($wpseo['deny_wp_json_crawling'])) {
            $added = RobotsDisallowRules::addLinesToAll(
                $current,
                ['Disallow: /wp-json/'],
                'yoast_import',
                'Imported from Yoast deny_wp_json_crawling'
            );
            if ($added > 0) {
                $report['applied'][] = 'robots_txt.disallow_rules (+/wp-json/)';
            } else {
                $report['skipped'][] = 'robots_txt.disallow_rules (+/wp-json/, already present)';
            }
        }

        if ($searchUrlsEnabled || $sanitizeQuery) {
            $this->appendUniqueReportWarning(
                $report,
                $label . ': search URL cleanup was imported best-effort. Review custom search filters, query cleanup rules, and blocked search patterns before enabling it on a live site.'
            );
        }

        if ($stripCategoryBase) {
            $this->appendUniqueReportWarning(
                $report,
                $label . ': stripcategorybase was imported as best-effort category-base removal. Review category permalink conflicts before relying on it site-wide.'
            );
        }
    }

    /**
     * @param array<string,mixed> $report
     */
    private function appendUniqueReportWarning(array &$report, string $message): void
    {
        $warnings = isset($report['warnings']) && is_array($report['warnings']) ? $report['warnings'] : [];
        if (!in_array($message, $warnings, true)) {
            $report['warnings'][] = $message;
        }
    }

    /**
     * @param array<string,mixed> $report
     */
    private function appendUniqueReportNotMapped(array &$report, string $message): void
    {
        $items = isset($report['not_mapped']) && is_array($report['not_mapped']) ? $report['not_mapped'] : [];
        if (!in_array($message, $items, true)) {
            $report['not_mapped'][] = $message;
        }
    }

    /**
     * Highlight Yoast settings that exist in the source but do not have a clean 1:1 import target.
     *
     * @param array<string,mixed> $titles
     * @param array<string,mixed> $report
     */
    private function appendYoastPortabilityWarnings(array $titles, bool $warnRobots, bool $warnSchema, bool $warnLocal, array &$report, string $label = 'Yoast export', bool $archiveControlsImported = false): void
    {
        if (empty($titles)) {
            return;
        }

        $lookup = [];
        foreach ($titles as $k => $v) {
            if (!is_string($k)) {
                continue;
            }
            $lookup[strtolower(trim($k))] = $v;
        }

        if ($warnRobots && !$archiveControlsImported) {
            $archiveKinds = [];
            if ($this->yoastValueIsEnabled($lookup['disable-author'] ?? null)) {
                $archiveKinds[] = 'author';
            }
            if ($this->yoastValueIsEnabled($lookup['disable-date'] ?? null)) {
                $archiveKinds[] = 'date';
            }
            if (!empty($archiveKinds)) {
                $this->appendUniqueReportWarning(
                    $report,
                    sprintf(
                        '%s: %s archive disabling is more specific than WP SEO Next archive defaults and was not imported automatically.',
                        $label,
                        implode('/', $archiveKinds)
                    )
                );
            }
        }

        unset($warnSchema, $warnLocal);
    }

    /**
     * @param mixed $value
     */
    private function yoastValueIsEnabled($value): bool
    {
        if (is_bool($value)) {
            return $value;
        }

        if (is_int($value) || is_float($value)) {
            return ((float) $value) !== 0.0;
        }

        if (is_string($value)) {
            $v = strtolower(trim($value));
            return !in_array($v, ['', '0', 'false', 'off', 'no', 'none', 'null'], true);
        }

        if (is_array($value)) {
            return !empty($value);
        }

        return !empty($value);
    }

    /**
     * @param array<string,mixed> $data
     */
    private function yoastGetFirstStringByKeys(array $data, array $keys): string
    {
        if (empty($data) || empty($keys)) {
            return '';
        }

        $lookup = [];
        foreach ($data as $k => $v) {
            if (!is_string($k)) {
                continue;
            }
            $lookup[strtolower(trim($k))] = $v;
        }

        foreach ($keys as $key) {
            $kk = strtolower(trim((string) $key));
            if ($kk === '' || !array_key_exists($kk, $lookup)) {
                continue;
            }

            $value = $lookup[$kk];
            if (is_string($value)) {
                $value = trim($value);
                if ($value !== '') {
                    return $value;
                }
                continue;
            }

            if (is_array($value)) {
                $parts = [];
                foreach ($value as $item) {
                    if (!is_scalar($item)) {
                        continue;
                    }
                    $item = trim((string) $item);
                    if ($item !== '') {
                        $parts[] = $item;
                    }
                }
                if (!empty($parts)) {
                    return implode("\n", $parts);
                }
            }

            if (is_scalar($value)) {
                $value = trim((string) $value);
                if ($value !== '') {
                    return $value;
                }
            }
        }

        return '';
    }

    /**
     * @param mixed $value
     */
    private function firstStringFromUnknownValue($value): string
    {
        if (is_string($value)) {
            $trim = trim($value);
            if ($trim === '') {
                return '';
            }

            if ($trim[0] === '{' || $trim[0] === '[') {
                $json = json_decode($trim, true);
                if (is_array($json)) {
                    $found = $this->firstStringFromUnknownValue($json);
                    if ($found !== '') {
                        return $found;
                    }
                }
            }

            if (function_exists('maybe_unserialize')) {
                $un = maybe_unserialize($trim);
                if ($un !== $trim) {
                    $found = $this->firstStringFromUnknownValue($un);
                    if ($found !== '') {
                        return $found;
                    }
                }
            }

            return $trim;
        }

        if (is_array($value)) {
            foreach ($value as $item) {
                $found = $this->firstStringFromUnknownValue($item);
                if ($found !== '') {
                    return $found;
                }
            }
            return '';
        }

        if (is_scalar($value)) {
            return trim((string) $value);
        }

        return '';
    }

    /**
     * @param mixed $value
     */
    private function resolveYoastContentReferenceUrl($value): string
    {
        $raw = trim($this->firstStringFromUnknownValue($value));
        if ($raw === '' || $raw === '0') {
            return '';
        }

        if (is_numeric($raw)) {
            $postId = (int) $raw;
            if ($postId > 0) {
                $url = get_permalink($postId);
                if (is_string($url) && $url !== '') {
                    return $this->cleanUrl($url);
                }
            }

            return '';
        }

        $url = $this->cleanUrl($raw);
        if ($url !== '' && preg_match('#^https?://#i', $url)) {
            return $url;
        }

        if ($raw !== '' && $raw[0] === '/') {
            return $this->cleanUrl(home_url($raw));
        }

        return '';
    }

    private function resolveYoastSchemaPostTypeKey(string $suffix): string
    {
        $suffix = strtolower(trim($suffix));
        if ($suffix === '') {
            return '';
        }

        $candidates = [$suffix, str_replace('-', '_', $suffix), str_replace('_', '-', $suffix)];
        $candidates = array_values(array_unique(array_filter(array_map('strval', $candidates))));

        foreach ($candidates as $candidate) {
            if ($candidate !== '' && post_type_exists($candidate)) {
                return $candidate;
            }
        }

        return '';
    }

    /**
     * Read the first non-empty post meta value from a list of candidate keys.
     *
     * @param array<int,string> $keys
     */
    private function getFirstPostMetaStringByKeys(int $postId, array $keys): string
    {
        if ($postId <= 0 || empty($keys)) {
            return '';
        }

        foreach ($keys as $key) {
            if (!is_string($key)) {
                continue;
            }

            $key = trim($key);
            if ($key === '') {
                continue;
            }

            $found = $this->firstStringFromUnknownValue(get_post_meta($postId, $key, true));
            if ($found !== '') {
                return $found;
            }
        }

        return '';
    }


    /**
     * @param mixed $value
     * @return array{url:string,id:string}
     */
    private function imageReferenceFromUnknownValue($value): array
    {
        $empty = ['url' => '', 'id' => ''];

        if (is_string($value)) {
            $trim = trim($value);
            if ($trim === '') {
                return $empty;
            }

            if ($trim[0] === '{' || $trim[0] === '[') {
                $json = json_decode($trim, true);
                if (is_array($json)) {
                    $ref = $this->imageReferenceFromUnknownValue($json);
                    if ($ref['url'] !== '' || $ref['id'] !== '') {
                        return $ref;
                    }
                }
            }

            if (function_exists('maybe_unserialize')) {
                $un = maybe_unserialize($trim);
                if ($un !== $trim) {
                    $ref = $this->imageReferenceFromUnknownValue($un);
                    if ($ref['url'] !== '' || $ref['id'] !== '') {
                        return $ref;
                    }
                }
            }

            if (is_numeric($trim)) {
                return ['url' => '', 'id' => (string) (int) $trim];
            }

            $url = $this->cleanUrl($trim);
            if ($url !== '') {
                return ['url' => $url, 'id' => ''];
            }

            return $empty;
        }

        if (is_array($value)) {
            $lookup = [];
            foreach ($value as $k => $v) {
                if (is_string($k)) {
                    $lookup[strtolower(trim($k))] = $v;
                }
            }

            $urlKeys = [
                'url',
                'src',
                'image',
                'image_url',
                'full',
                'full_url',
                'og_image',
                'opengraph_image',
                'open_graph_image',
                'twitter_image',
                'value',
            ];
            foreach ($urlKeys as $key) {
                if (!array_key_exists($key, $lookup)) {
                    continue;
                }
                $ref = $this->imageReferenceFromUnknownValue($lookup[$key]);
                if ($ref['url'] !== '' || $ref['id'] !== '') {
                    return $ref;
                }
            }

            $idKeys = [
                'id',
                'attachment_id',
                'image_id',
                'media_id',
                'img_id',
            ];
            foreach ($idKeys as $key) {
                if (!array_key_exists($key, $lookup)) {
                    continue;
                }
                $ref = $this->imageReferenceFromUnknownValue($lookup[$key]);
                if ($ref['url'] !== '' || $ref['id'] !== '') {
                    return $ref;
                }
            }

            foreach ($value as $item) {
                $ref = $this->imageReferenceFromUnknownValue($item);
                if ($ref['url'] !== '' || $ref['id'] !== '') {
                    return $ref;
                }
            }

            return $empty;
        }

        if (is_scalar($value)) {
            $string = trim((string) $value);
            if ($string === '') {
                return $empty;
            }
            if (is_numeric($string)) {
                return ['url' => '', 'id' => (string) (int) $string];
            }
            $url = $this->cleanUrl($string);
            if ($url !== '') {
                return ['url' => $url, 'id' => ''];
            }
        }

        return $empty;
    }

    /**
     * @param array<int,string> $keys
     */
    private function getFirstPostMetaImageUrlByKeys(int $postId, array $keys): string
    {
        if ($postId <= 0 || empty($keys)) {
            return '';
        }

        foreach ($keys as $key) {
            if (!is_string($key)) {
                continue;
            }

            $key = trim($key);
            if ($key === '') {
                continue;
            }

            $ref = $this->imageReferenceFromUnknownValue(get_post_meta($postId, $key, true));
            $resolved = $this->resolveImageUrl($ref['url'], $ref['id']);
            if ($resolved !== '') {
                return $resolved;
            }
        }

        return '';
    }

    /**
     * @param array<string,mixed> $data
     * @param array<int,string> $keys
     */
    private function yoastGetFirstImageUrlByKeys(array $data, array $keys): string
    {
        if (empty($data) || empty($keys)) {
            return '';
        }

        $lookup = [];
        foreach ($data as $k => $v) {
            if (!is_string($k)) {
                continue;
            }
            $lookup[strtolower(trim($k))] = $v;
        }

        foreach ($keys as $key) {
            $kk = strtolower(trim((string) $key));
            if ($kk === '' || !array_key_exists($kk, $lookup)) {
                continue;
            }

            $ref = $this->imageReferenceFromUnknownValue($lookup[$kk]);
            $resolved = $this->resolveImageUrl($ref['url'], $ref['id']);
            if ($resolved !== '') {
                return $resolved;
            }
        }

        return '';
    }

    /**
     * @param array<string,mixed> $ini
     */
    private function yoastIniHasRequiredSections(array $ini): bool
    {
        return isset($ini['wpseo_titles']) && is_array($ini['wpseo_titles']) && !empty($ini['wpseo_titles']);
    }

    /**
     * @param array<string,mixed> $data
     * @param array<int,array{0:array<int,string>,1:array<int,string>}> $candidatePairs
     */
    private function yoastResolveImageByCandidates(array $data, array $candidatePairs): string
    {
        if (empty($data) || empty($candidatePairs)) {
            return '';
        }

        foreach ($candidatePairs as $pair) {
            $urlKeys = isset($pair[0]) && is_array($pair[0]) ? $pair[0] : [];
            $idKeys = isset($pair[1]) && is_array($pair[1]) ? $pair[1] : [];

            $resolved = $this->yoastGetFirstImageUrlByKeys($data, $urlKeys);
            if ($resolved !== '') {
                return $resolved;
            }

            $resolved = $this->yoastGetFirstImageUrlByKeys($data, $idKeys);
            if ($resolved !== '') {
                return $resolved;
            }
        }

        return '';
    }

    /**
     * @param array<string,mixed> $titles
     */
    private function yoastPreferredEntityName(array $titles, string $who = ''): string
    {
        $who = strtolower(trim($who));

        if ($who === 'person') {
            return $this->yoastGetFirstStringByKeys($titles, [
                'person_name',
                'company_name',
                'org-legal-name',
                'website_name',
                'alternate_website_name',
            ]);
        }

        return $this->yoastGetFirstStringByKeys($titles, [
            'company_name',
            'org-legal-name',
            'person_name',
            'website_name',
            'alternate_website_name',
        ]);
    }

    /**
     * @param array<string,mixed> $titles
     */
    private function yoastPreferredEntityLogoUrl(array $titles, string $who = ''): string
    {
        $who = strtolower(trim($who));

        if ($who === 'person') {
            return $this->yoastResolveImageByCandidates($titles, [
                [['person_logo', 'person_logo_meta'], ['person_logo_id']],
                [['company_logo', 'company_logo_meta'], ['company_logo_id']],
            ]);
        }

        return $this->yoastResolveImageByCandidates($titles, [
            [['company_logo', 'company_logo_meta'], ['company_logo_id']],
            [['person_logo', 'person_logo_meta'], ['person_logo_id']],
        ]);
    }

    /**
     * @param array<string,mixed> $titles
     * @param array<string,mixed> $social
     */
    private function yoastPreferredDefaultSocialImageUrl(array $titles, array $social, string $who = ''): string
    {
        $who = strtolower(trim($who));

        $img = $this->yoastResolveImageByCandidates($social, [
            [['og_default_image', 'og_default_image_meta'], ['og_default_image_id']],
        ]);
        if ($img !== '') {
            return $img;
        }

        if ($who === 'person') {
            return $this->yoastResolveImageByCandidates($titles, [
                [['person_logo', 'person_logo_meta'], ['person_logo_id']],
                [['company_logo', 'company_logo_meta'], ['company_logo_id']],
                [['open_graph_frontpage_image', 'open_graph_frontpage_image_meta'], ['open_graph_frontpage_image_id']],
            ]);
        }

        return $this->yoastResolveImageByCandidates($titles, [
            [['company_logo', 'company_logo_meta'], ['company_logo_id']],
            [['person_logo', 'person_logo_meta'], ['person_logo_id']],
            [['open_graph_frontpage_image', 'open_graph_frontpage_image_meta'], ['open_graph_frontpage_image_id']],
        ]);
    }

    /**
     * @param array<string,mixed> $report
     */
    private function yoastSeparatorToChar(string $raw, array &$report): string
    {
        $raw = trim($raw);
        if ($raw === '') {
            return '';
        }

        // Yoast uses codes like "sc-dash".
        $map = [
            'sc-dash' => '-',
            'sc-mdash' => '—',
            'sc-ndash' => '–',
            'sc-middot' => '·',
            'sc-bull' => '•',
            'sc-star' => '*',
            'sc-smstar' => '⋆',
            'sc-pipe' => '|',
            'sc-gt' => '>',
            'sc-lt' => '<',
            'sc-tilde' => '~',
        ];

        if (isset($map[$raw])) {
            return (string) $map[$raw];
        }

        if (strpos($raw, 'sc-') === 0) {
            $report['warnings'][] = 'Yoast export: unknown separator code "' . $raw . '" (kept WP SEO Next default).';
            return '';
        }

        // Already a literal separator.
        return $raw;
    }

    private function yoastTemplateToWpsn(string $tpl): string
    {
        $tpl = trim($tpl);
        if ($tpl === '') {
            return '';
        }

        // Replace Yoast tokens %%token%% with WPSN tokens %token%.
        $map = [
            'title' => 'title',
            'sitename' => 'sitename',
            'sitedesc' => 'sitedesc',
            'tagline' => 'tagline',
            'sep' => 'sep',
            'page' => 'page',
            'pagenum' => 'pagenum',

            // Author
            'name' => 'author_name',
            'firstname' => 'author_first_name',
            'lastname' => 'author_last_name',

            // Post type
            'pt_plural' => 'post_type_plural',
            'pt_single' => 'post_type_singular',

            // Term
            'term_title' => 'title',
            'term_description' => 'term_description',
        ];

        $tpl = preg_replace_callback('/%%([a-zA-Z0-9_\-]+)%%/', function ($m) use ($map) {
            $k = isset($m[1]) ? (string) $m[1] : '';
            $k = strtolower(trim($k));
            $out = isset($map[$k]) ? (string) $map[$k] : $k;
            // Convert to %token% (TemplateEngine will resolve or empty).
            return '%' . $out . '%';
        }, $tpl) ?: $tpl;

        // Cleanup: Yoast sometimes leaves double spaces; keep it readable.
        $tpl = trim(preg_replace('/\s+/u', ' ', $tpl) ?? $tpl);
        return $tpl;
    }

    private function rankMathTemplateToWpsn(string $tpl): string
    {
        $tpl = trim($tpl);
        if ($tpl === '') {
            return '';
        }

        // Rank Math tokens are already %token% but variable names differ.
        $map = [
            // Author
            'name' => 'author_name',

            // Term
            'term' => 'term_name',
        ];

        $tpl = preg_replace_callback('/%([a-zA-Z0-9_\-]+)%/', function ($m) use ($map) {
            $k = isset($m[1]) ? (string) $m[1] : '';
            $k = strtolower(trim($k));
            $out = isset($map[$k]) ? (string) $map[$k] : $k;
            return '%' . $out . '%';
        }, $tpl) ?: $tpl;

        $tpl = trim(preg_replace('/\s+/u', ' ', $tpl) ?? $tpl);
        return $tpl;
    }

    private function seopressTemplateToWpsn(string $tpl): string
    {
        $tpl = trim($tpl);
        if ($tpl === '') {
            return '';
        }

        // SEOPress commonly uses %%token%%.
        $map = [
            // Site
            'sitetitle' => 'sitename',
            'site_title' => 'sitename',
            'tagline' => 'sitedesc',
            'site_desc' => 'sitedesc',
            'sep' => 'sep',

            // Post
            'post_title' => 'title',
            'title' => 'title',
            'excerpt' => 'excerpt',
            'post_excerpt' => 'excerpt',

            // Term
            'term_title' => 'title',
            'term_description' => 'term_description',

            // Pagination
            'page' => 'page',
            'pagenum' => 'pagenum',
            'current_pagination' => 'pagenum',

            // Author
            'author_name' => 'author_name',
            'author' => 'author_name',
        ];

        $tpl = preg_replace_callback('/%%([a-zA-Z0-9_\-]+)%%/', function ($m) use ($map) {
            $k = isset($m[1]) ? (string) $m[1] : '';
            $k = strtolower(trim($k));
            $out = isset($map[$k]) ? (string) $map[$k] : $k;
            return '%' . $out . '%';
        }, $tpl) ?: $tpl;

        $tpl = trim(preg_replace('/\s+/u', ' ', $tpl) ?? $tpl);
        return $tpl;
    }

    private function aioseoTemplateToWpsn(string $tpl): string
    {
        $tpl = trim($tpl);
        if ($tpl === '') {
            return '';
        }

        // AIOSEO commonly uses #token# or %token% depending on version.
        $map = [
            'site_title' => 'sitename',
            'sitename' => 'sitename',
            'site_description' => 'sitedesc',
            'sitedesc' => 'sitedesc',
            'separator' => 'sep',
            'sep' => 'sep',

            'post_title' => 'title',
            'title' => 'title',
            'excerpt' => 'excerpt',

            'author_name' => 'author_name',
            'author' => 'author_name',

            'page_number' => 'pagenum',
            'pagenum' => 'pagenum',
            'page' => 'page',
        ];

        $tpl = preg_replace_callback('/#([a-zA-Z0-9_\-]+)#/', function ($m) use ($map) {
            $k = isset($m[1]) ? (string) $m[1] : '';
            $k = strtolower(trim($k));
            $out = isset($map[$k]) ? (string) $map[$k] : $k;
            return '%' . $out . '%';
        }, $tpl) ?: $tpl;

        $tpl = preg_replace_callback('/%([a-zA-Z0-9_\-]+)%/', function ($m) use ($map) {
            $k = isset($m[1]) ? (string) $m[1] : '';
            $k = strtolower(trim($k));
            $out = isset($map[$k]) ? (string) $map[$k] : $k;
            return '%' . $out . '%';
        }, $tpl) ?: $tpl;

        $tpl = trim(preg_replace('/\s+/u', ' ', $tpl) ?? $tpl);
        return $tpl;
    }

    /**
     * Normalize values that SEOPress/AIOSEO sometimes store as serialized strings.
     *
     * @return mixed
     */
    private function normalizeMaybeSerialized($value)
    {
        if (is_string($value)) {
            $value = trim($value);
            if ($value === '') {
                return [];
            }

            // JSON inside JSON (rare) or serialized PHP.
            if ($value[0] === '{' || $value[0] === '[') {
                $json = json_decode($value, true);
                if (is_array($json)) {
                    return $json;
                }
            }

            if (function_exists('maybe_unserialize')) {
                $un = maybe_unserialize($value);
                if (is_array($un)) {
                    return $un;
                }
            }

            return $value;
        }

        return $value;
    }

    private function looksLikeUrl(string $url): bool
    {
        $url = trim($url);
        if ($url === '') {
            return false;
        }
        if (function_exists('wp_http_validate_url')) {
            return (bool) wp_http_validate_url($url);
        }
        return (bool) filter_var($url, FILTER_VALIDATE_URL);
    }

    private function looksLikeTemplate(string $s): bool
    {
        $s = trim($s);
        if ($s === '') {
            return false;
        }
        // Heuristic: contains placeholders or a separator token.
        return (strpos($s, '%') !== false) || (strpos($s, '%%') !== false) || (strpos($s, '#') !== false);
    }

    /**
     * @param array<string,mixed> $arr
     */
    private function findFirstStringValueByKeyContains(array $arr, array $needles): string
    {
        $needles = array_values(array_filter(array_map('strtolower', array_map('strval', $needles))));
        foreach ($arr as $k => $v) {
            if (!is_string($k) || !is_string($v)) {
                continue;
            }
            $kk = strtolower($k);
            $ok = true;
            foreach ($needles as $n) {
                if ($n !== '' && strpos($kk, $n) === false) {
                    $ok = false;
                    break;
                }
            }
            if ($ok && trim($v) !== '') {
                return trim($v);
            }
        }
        return '';
    }

    /**
     * @param array<string,mixed> $arr
     */
    private function findFirstUrlValueByKeyContains(array $arr, array $needles): string
    {
        $needles = array_values(array_filter(array_map('strtolower', array_map('strval', $needles))));
        foreach ($arr as $k => $v) {
            if (!is_string($k) || !is_string($v)) {
                continue;
            }
            $kk = strtolower($k);
            $ok = true;
            foreach ($needles as $n) {
                if ($n !== '' && strpos($kk, $n) === false) {
                    $ok = false;
                    break;
                }
            }
            $vv = trim($v);
            if ($ok && $vv !== '' && $this->looksLikeUrl($vv)) {
                return $vv;
            }
        }
        return '';
    }

    /**
     * Find first string value where key matches any of the provided names (deep).
     *
     * @param mixed $data
     */
    private function deepFindFirstStringByKeyAny($data, array $keys): string
    {
        $keys = array_values(array_filter(array_map('strtolower', array_map('strval', $keys))));
        if (empty($keys)) {
            return '';
        }

        $stack = [$data];
        $seen = 0;
        while (!empty($stack) && $seen < 50000) {
            $cur = array_pop($stack);
            if (!is_array($cur)) {
                continue;
            }
            foreach ($cur as $k => $v) {
                $seen++;
                if (is_string($k)) {
                    $kk = strtolower($k);
                    if (in_array($kk, $keys, true) && is_string($v) && trim($v) !== '') {
                        return trim($v);
                    }
                }
                if (is_array($v)) {
                    $stack[] = $v;
                }
            }
        }
        return '';
    }

    /**
     * Find first URL value where key matches any of the provided names (deep).
     *
     * @param mixed $data
     */
    private function deepFindFirstUrlByKeyAny($data, array $keys): string
    {
        $keys = array_values(array_filter(array_map('strtolower', array_map('strval', $keys))));
        if (empty($keys)) {
            return '';
        }

        $stack = [$data];
        $seen = 0;
        while (!empty($stack) && $seen < 50000) {
            $cur = array_pop($stack);
            if (!is_array($cur)) {
                continue;
            }
            foreach ($cur as $k => $v) {
                $seen++;
                if (is_string($k)) {
                    $kk = strtolower($k);
                    if (in_array($kk, $keys, true) && is_string($v)) {
                        $vv = trim($v);
                        if ($vv !== '' && $this->looksLikeUrl($vv)) {
                            return $vv;
                        }
                    }
                }
                if (is_array($v)) {
                    $stack[] = $v;
                }
            }
        }
        return '';
    }

    /**
     * Collect URLs from values where key contains all needles (deep).
     *
     * @param mixed $data
     * @return array<int,string>
     */
    private function deepCollectUrlsByKeyContains($data, array $needles): array
    {
        $needles = array_values(array_filter(array_map('strtolower', array_map('strval', $needles))));
        if (empty($needles)) {
            return [];
        }

        $out = [];
        $stack = [$data];
        $seen = 0;
        while (!empty($stack) && $seen < 80000) {
            $cur = array_pop($stack);
            if (!is_array($cur)) {
                continue;
            }
            foreach ($cur as $k => $v) {
                $seen++;
                if (is_array($v)) {
                    $stack[] = $v;
                }
                if (!is_string($k) || !is_string($v)) {
                    continue;
                }
                $kk = strtolower($k);
                $ok = true;
                foreach ($needles as $n) {
                    if ($n !== '' && strpos($kk, $n) === false) {
                        $ok = false;
                        break;
                    }
                }
                if (!$ok) {
                    continue;
                }
                $vv = trim($v);
                if ($vv !== '' && $this->looksLikeUrl($vv)) {
                    $out[] = $vv;
                }
            }
        }

        return $out;
    }

    /**
     * Best-effort separator extraction for unknown structures.
     *
     * @param mixed $data
     */
    private function deepFindFirstLikelySeparator($data): string
    {
        $candidates = ['-', '—', '–', '·', '•', '*', '⋆', '|', '>', '<', '~'];

        $stack = [$data];
        $seen = 0;
        while (!empty($stack) && $seen < 50000) {
            $cur = array_pop($stack);
            if (!is_array($cur)) {
                continue;
            }
            foreach ($cur as $k => $v) {
                $seen++;
                if (is_array($v)) {
                    $stack[] = $v;
                    continue;
                }
                if (!is_string($k) || !is_string($v)) {
                    continue;
                }
                $kk = strtolower($k);
                if (strpos($kk, 'sep') === false && strpos($kk, 'separator') === false) {
                    continue;
                }
                $vv = trim($v);
                if ($vv !== '' && in_array($vv, $candidates, true)) {
                    return $vv;
                }
            }
        }

        return '';
    }


    /**
     * Parse truthy / falsy values coming from other plugins (best-effort).
     *
     * @param mixed $value
     */
    private function parseBoolLoose($value): ?bool
    {
        if (is_bool($value)) {
            return (bool) $value;
        }

        if (is_int($value) || is_float($value)) {
            return ((int) $value) !== 0;
        }

        if (is_string($value)) {
            $s = strtolower(trim((string) $value));
            if ($s === '') {
                return null;
            }

            if (in_array($s, ['1', 'true', 'yes', 'y', 'on', 'enabled', 'enable'], true)) {
                return true;
            }

            if (in_array($s, ['0', 'false', 'no', 'n', 'off', 'disabled', 'disable'], true)) {
                return false;
            }

            if (is_numeric($s)) {
                return ((int) $s) !== 0;
            }
        }

        return null;
    }

    /**
     * Deep find a boolean-ish value by key "contains" match (all needles must be present).
     *
     * @param mixed $data
     */
    private function deepFindFirstBoolByKeyContains($data, array $needles): ?bool
    {
        $needles = array_values(array_filter(array_map('strtolower', array_map('strval', $needles))));
        if (empty($needles)) {
            return null;
        }

        $stack = [$data];
        $seen = 0;
        while (!empty($stack) && $seen < 50000) {
            $cur = array_pop($stack);
            if (!is_array($cur)) {
                continue;
            }
            foreach ($cur as $k => $v) {
                $seen++;
                if (is_array($v)) {
                    $stack[] = $v;
                    continue;
                }
                if (!is_string($k)) {
                    continue;
                }
                $kk = strtolower((string) $k);
                $ok = true;
                foreach ($needles as $n) {
                    if ($n !== '' && strpos($kk, $n) === false) {
                        $ok = false;
                        break;
                    }
                }
                if (!$ok) {
                    continue;
                }
                $b = $this->parseBoolLoose($v);
                if ($b !== null) {
                    return $b;
                }
            }
        }

        return null;
    }

    /**
     * Deep find a boolean-ish value by key name (exact match).
     *
     * @param mixed $data
     */
    private function deepFindFirstBoolByKeyAny($data, array $keys): ?bool
    {
        $keys = array_values(array_filter(array_map('strtolower', array_map('strval', $keys))));
        if (empty($keys)) {
            return null;
        }

        $stack = [$data];
        $seen = 0;
        while (!empty($stack) && $seen < 50000) {
            $cur = array_pop($stack);
            if (!is_array($cur)) {
                continue;
            }
            foreach ($cur as $k => $v) {
                $seen++;
                if (is_array($v)) {
                    $stack[] = $v;
                    continue;
                }
                if (!is_string($k)) {
                    continue;
                }
                $kk = strtolower((string) $k);
                if (!in_array($kk, $keys, true)) {
                    continue;
                }
                $b = $this->parseBoolLoose($v);
                if ($b !== null) {
                    return $b;
                }
            }
        }

        return null;
    }

    /**
     * Deep find first string by key contains (all needles must be present).
     *
     * @param mixed $data
     */
    private function deepFindFirstStringByKeyContains($data, array $needles): string
    {
        $needles = array_values(array_filter(array_map('strtolower', array_map('strval', $needles))));
        if (empty($needles)) {
            return '';
        }

        $stack = [$data];
        $seen = 0;
        while (!empty($stack) && $seen < 50000) {
            $cur = array_pop($stack);
            if (!is_array($cur)) {
                continue;
            }
            foreach ($cur as $k => $v) {
                $seen++;
                if (is_array($v)) {
                    $stack[] = $v;
                    continue;
                }
                if (!is_string($k) || !is_string($v)) {
                    continue;
                }
                $kk = strtolower((string) $k);
                $ok = true;
                foreach ($needles as $n) {
                    if ($n !== '' && strpos($kk, $n) === false) {
                        $ok = false;
                        break;
                    }
                }
                if ($ok && trim($v) !== '') {
                    return trim($v);
                }
            }
        }

        return '';
    }

    /**
     * Deep find first nested array by key contains (all needles must be present).
     *
     * @param mixed $data
     * @return array<string,mixed>|null
     */
    private function deepFindFirstArrayByKeyContains($data, array $needles): ?array
    {
        $needles = array_values(array_filter(array_map('strtolower', array_map('strval', $needles))));
        if (empty($needles)) {
            return null;
        }

        $stack = [$data];
        $seen = 0;
        while (!empty($stack) && $seen < 50000) {
            $cur = array_pop($stack);
            if (!is_array($cur)) {
                continue;
            }
            foreach ($cur as $k => $v) {
                $seen++;
                if (!is_string($k)) {
                    if (is_array($v)) {
                        $stack[] = $v;
                    }
                    continue;
                }
                $kk = strtolower((string) $k);
                $ok = true;
                foreach ($needles as $n) {
                    if ($n !== '' && strpos($kk, $n) === false) {
                        $ok = false;
                        break;
                    }
                }
                if ($ok && is_array($v)) {
                    return $v;
                }
                if (is_array($v)) {
                    $stack[] = $v;
                }
            }
        }

        return null;
    }

    /**
     * Apply scalar setting at path when allowed.
     *
     * @param array<string,mixed> $current
     * @param array<string,mixed> $defaults
     * @param array<string,mixed> $report
     */
    private function maybeApplySettingScalar(array &$current, array $defaults, string $path, $value, bool $overwrite, array &$report): void
    {
        $path = trim($path);
        if ($path === '') {
            return;
        }

        // Normalize bool as int-ish.
        if (is_bool($value)) {
            $value = (bool) $value;
        }

        $cur = $this->arrayGetPath($current, $path);
        $def = $this->arrayGetPath($defaults, $path);

        $canApply = $overwrite;

        // If not overwriting, apply only if current is empty or equals default.
        if (!$canApply) {
            if (is_string($cur)) {
                $canApply = (trim($cur) === '') || (is_string($def) && $cur === $def);
            } elseif (is_bool($cur)) {
                $canApply = is_bool($def) ? ($cur === $def) : false;
            } else {
                $canApply = empty($cur) || ($def !== null && $cur === $def);
            }
        }

        if (!$canApply) {
            $report['skipped'][] = $path;
            return;
        }

        $this->arraySetPath($current, $path, $value);
        $report['applied'][] = $path;
    }

    /**
     * @param array<string,mixed> $arr
     */
    private function arrayGetPath(array $arr, string $path)
    {
        $parts = explode('.', $path);
        $cur = $arr;
        foreach ($parts as $p) {
            $p = (string) $p;
            if ($p === '') {
                return null;
            }
            if (!is_array($cur) || !array_key_exists($p, $cur)) {
                return null;
            }
            $cur = $cur[$p];
        }
        return $cur;
    }

    /**
     * @param array<string,mixed> $arr
     */
    private function arraySetPath(array &$arr, string $path, $value): void
    {
        $parts = explode('.', $path);
        $cur =& $arr;
        foreach ($parts as $i => $p) {
            $p = (string) $p;
            if ($p === '') {
                return;
            }
            if ($i === count($parts) - 1) {
                $cur[$p] = $value;
                return;
            }
            if (!isset($cur[$p]) || !is_array($cur[$p])) {
                $cur[$p] = [];
            }
            $cur =& $cur[$p];
        }
    }

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

        check_admin_referer('wpsn_start_migration');

        $source = isset($_POST['source']) ? sanitize_key((string) wp_unslash($_POST['source'])) : '';
        $allowedSources = array_keys($this->getSourceInfo());
        if (!in_array($source, $allowedSources, true)) {
            $source = 'yoast';
        }

        $postTypes = [];
        if (!empty($_POST['post_types']) && is_array($_POST['post_types'])) {
            foreach ($_POST['post_types'] as $pt) {
                $pt = sanitize_key((string) $pt);
                if ($pt !== '') {
                    $postTypes[] = $pt;
                }
            }
        }

        $selectable = $this->getSelectablePostTypes();
        $postTypes = array_values(array_intersect(array_unique($postTypes), $selectable));
        if (empty($postTypes)) {
            $postTypes = $selectable;
        }

        $overwrite = !empty($_POST['overwrite']);
        $includeTerms = !empty($_POST['include_terms']);
        $includeSocial = !empty($_POST['include_social']);
        $includeSchema = !empty($_POST['include_schema']);

        // SEOPress schema settings are complex (arrays/custom schemas). Importing them safely requires
        // deeper mapping, so we disable schema-type migration for now.
        if ($source === 'seopress') {
            $includeSchema = false;
        }

        $jobId = function_exists('wp_generate_uuid4') ? wp_generate_uuid4() : uniqid('wpsn_', true);

        $total = $this->countPosts($postTypes);

        $job = [
            'id'            => $jobId,
            'user_id'       => get_current_user_id(),
            'source'        => $source,
            'post_types'    => $postTypes,
            'overwrite'     => $overwrite,
            'include_terms' => $includeTerms,
            'include_social'=> $includeSocial,
            'include_schema'=> $includeSchema,
            'batch'         => self::BATCH_SIZE,
            'stage'         => 'posts',
            'last_id'       => 0,
            'processed'     => 0,
            'total'         => $total,
            'counts'        => [
                'posts_updated'    => 0,
                'titles'           => 0,
                'titles_overwritten' => 0,
                'titles_skipped_existing' => 0,
                'titles_missing_source' => 0,
                'descriptions'     => 0,
                'descriptions_overwritten' => 0,
                'descriptions_skipped_existing' => 0,
                'descriptions_missing_source' => 0,
                'focus_keywords'     => 0,
                'focus_keywords_overwritten' => 0,
                'focus_keywords_skipped_existing' => 0,
                'focus_keywords_missing_source' => 0,
                'canonicals'       => 0,
                'canonicals_overwritten' => 0,
                'canonicals_skipped_existing' => 0,
                'canonicals_missing_source' => 0,
                'robots_index'     => 0,
                'robots_index_overwritten' => 0,
                'robots_index_skipped_existing' => 0,
                'robots_index_missing_source' => 0,
                'robots_follow'    => 0,
                'robots_follow_overwritten' => 0,
                'robots_follow_skipped_existing' => 0,
                'robots_follow_missing_source' => 0,
                'og_titles' => 0,
                'og_titles_overwritten' => 0,
                'og_titles_skipped_existing' => 0,
                'og_titles_missing_source' => 0,
                'og_descriptions' => 0,
                'og_descriptions_overwritten' => 0,
                'og_descriptions_skipped_existing' => 0,
                'og_descriptions_missing_source' => 0,
                'og_images' => 0,
                'og_images_overwritten' => 0,
                'og_images_skipped_existing' => 0,
                'og_images_missing_source' => 0,
                'twitter_titles' => 0,
                'twitter_titles_overwritten' => 0,
                'twitter_titles_skipped_existing' => 0,
                'twitter_titles_missing_source' => 0,
                'twitter_descriptions' => 0,
                'twitter_descriptions_overwritten' => 0,
                'twitter_descriptions_skipped_existing' => 0,
                'twitter_descriptions_missing_source' => 0,
                'twitter_images' => 0,
                'twitter_images_overwritten' => 0,
                'twitter_images_skipped_existing' => 0,
                'twitter_images_missing_source' => 0,
                'schema_types' => 0,
                'schema_types_overwritten' => 0,
                'schema_types_skipped_existing' => 0,
                'schema_types_missing_source' => 0,
                'skipped_existing' => 0,
            ],
            'terms'         => [
                'processed' => 0,
                'updated'   => 0,
                'last_tax'  => '',
                'last_term' => 0,
            ],
            'started_at'    => time(),
        ];

        $this->setJob($jobId, $job);

        /**
         * Fired when a migration job is started.
         *
         * @param string $source
         * @param array<int,string> $postTypes
         * @param array<string,mixed> $meta
         */
        do_action('wpsn/import_export/migration_started', (string) $source, $postTypes, [
            'job_id' => (string) $jobId,
            'overwrite' => $overwrite ? 1 : 0,
            'include_terms' => $includeTerms ? 1 : 0,
            'include_social' => $includeSocial ? 1 : 0,
            'include_schema' => $includeSchema ? 1 : 0,
            'total' => (int) $total,
        ]);

        wp_safe_redirect($this->pageUrl([
            'wpsn_notice' => 'migration_started',
            'job'         => $jobId,
            'autorun'     => '1',
        ]));
        exit;
    }

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

        $jobId = isset($_GET['job']) ? sanitize_text_field((string) wp_unslash($_GET['job'])) : '';
        if ($jobId === '') {
            $this->redirectNotice('migration_job_missing');
        }

        check_admin_referer('wpsn_migration_' . $jobId);

        $job = $this->getJob($jobId);
        if (!is_array($job) || empty($job['id'])) {
            $this->redirectNotice('migration_job_missing');
        }

        if ((int) ($job['user_id'] ?? 0) !== get_current_user_id()) {
            wp_die(esc_html__('This migration job belongs to a different user.', 'wp-seo-next'));
        }

        try {
            $this->processJob($job);
        } catch (\Throwable $e) {
            $this->logger->error('Migration failed: ' . $e->getMessage());
            $this->redirectNotice('migration_failed', ['job' => $jobId]);
        }

        $this->setJob($jobId, $job);

        if (($job['stage'] ?? '') === 'done') {
            /**
             * Fired when a migration job completes.
             *
             * @param string $jobId
             * @param string $source
             * @param array<string,mixed> $summary
             */
            $counts = isset($job['counts']) && is_array($job['counts']) ? $job['counts'] : [];
            $terms = isset($job['terms']) && is_array($job['terms']) ? $job['terms'] : [];
            do_action('wpsn/import_export/migration_completed', (string) $jobId, (string) ($job['source'] ?? ''), [
                'posts_total' => (int) ($job['total'] ?? 0),
                'posts_processed' => (int) ($job['processed'] ?? 0),
                'posts_updated' => (int) ($counts['posts_updated'] ?? 0),
                'skipped_existing_total' => (int) ($counts['skipped_existing'] ?? 0),
                'terms_processed' => (int) ($terms['processed'] ?? 0),
                'terms_updated' => (int) ($terms['updated'] ?? 0),
            ]);
            $this->recordImportAssistMigration($job);

            wp_safe_redirect($this->pageUrl([
                'wpsn_notice' => 'migration_done',
                'job'         => $jobId,
            ]));
            exit;
        }

        // Continue automatically.
        wp_safe_redirect($this->pageUrl([
            'job'     => $jobId,
            'autorun' => '1',
        ]));
        exit;
    }

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

        $jobId = isset($_GET['job']) ? sanitize_text_field((string) wp_unslash($_GET['job'])) : '';
        if ($jobId === '') {
            $this->redirectNotice('migration_job_missing');
        }

        check_admin_referer('wpsn_migration_' . $jobId);

        $job = $this->getJob($jobId);
        if (is_array($job) && !empty($job['source'])) {
            /**
             * Fired when a migration job is cancelled.
             *
             * @param string $jobId
             * @param string $source
             */
            do_action('wpsn/import_export/migration_cancelled', (string) $jobId, (string) ($job['source'] ?? ''));
        }

        $this->deleteJob($jobId);

        wp_safe_redirect($this->pageUrl([
            'wpsn_notice' => 'migration_canceled',
        ]));
        exit;
    }

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

        $jobId = isset($_GET['job']) ? sanitize_text_field((string) wp_unslash($_GET['job'])) : '';
        if ($jobId === '') {
            $this->redirectNotice('migration_job_missing');
        }

        check_admin_referer('wpsn_migration_report_' . $jobId);

        $job = $this->getJob($jobId);
        if (!is_array($job) || empty($job['id'])) {
            $this->redirectNotice('migration_job_missing');
        }

        if ((int) ($job['user_id'] ?? 0) !== get_current_user_id()) {
            wp_die(esc_html__('This migration job belongs to a different user.', 'wp-seo-next'));
        }

        $report = $this->buildMigrationReport($job);

        $filename = 'wpsn-migration-report-' . preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $jobId) . '.json';
        nocache_headers();
        header('Content-Type: application/json; charset=' . get_bloginfo('charset'));
        header('Content-Disposition: attachment; filename=' . $filename);

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

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

        $jobId = isset($_GET['job']) ? sanitize_text_field((string) wp_unslash($_GET['job'])) : '';
        if ($jobId === '') {
            $this->redirectNotice('migration_job_missing');
        }

        check_admin_referer('wpsn_migration_report_' . $jobId);

        $job = $this->getJob($jobId);
        if (!is_array($job) || empty($job['id'])) {
            $this->redirectNotice('migration_job_missing');
        }

        if ((int) ($job['user_id'] ?? 0) !== get_current_user_id()) {
            wp_die(esc_html__('This migration job belongs to a different user.', 'wp-seo-next'));
        }

        $report = $this->buildMigrationReport($job);

        $filename = 'wpsn-migration-report-' . preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $jobId) . '.csv';
        nocache_headers();
        header('Content-Type: text/csv; charset=' . get_bloginfo('charset'));
        header('Content-Disposition: attachment; filename=' . $filename);

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

        // Header.
        fputcsv($out, ['key', 'value']);

        // Job info.
        fputcsv($out, ['job_id', (string) ($report['job']['id'] ?? '')]);
        fputcsv($out, ['source', (string) ($report['job']['source'] ?? '')]);
        fputcsv($out, ['post_types', implode('|', (array) ($report['job']['post_types'] ?? []))]);
        fputcsv($out, ['overwrite', !empty($report['job']['overwrite']) ? '1' : '0']);
        fputcsv($out, ['include_social', !empty($report['job']['include_social']) ? '1' : '0']);
        fputcsv($out, ['include_schema', !empty($report['job']['include_schema']) ? '1' : '0']);
        fputcsv($out, ['posts_total', (string) ((int) ($report['job']['total'] ?? 0))]);
        fputcsv($out, ['posts_processed', (string) ((int) ($report['job']['processed'] ?? 0))]);
        fputcsv($out, ['posts_updated', (string) ((int) ($report['counts']['posts_updated'] ?? 0))]);

        // Field breakdown.
        $fields = isset($report['fields']) && is_array($report['fields']) ? $report['fields'] : [];
        foreach ($fields as $fieldKey => $row) {
            if (!is_array($row)) {
                continue;
            }
            $prefix = 'field_' . $fieldKey . '_';
            fputcsv($out, [$prefix . 'imported', (string) ((int) ($row['imported'] ?? 0))]);
            fputcsv($out, [$prefix . 'overwritten', (string) ((int) ($row['overwritten'] ?? 0))]);
            fputcsv($out, [$prefix . 'skipped_existing', (string) ((int) ($row['skipped_existing'] ?? 0))]);
            fputcsv($out, [$prefix . 'missing_source', (string) ((int) ($row['missing_source'] ?? 0))]);
        }

        // Terms summary (if included).
        $terms = isset($report['terms']) && is_array($report['terms']) ? $report['terms'] : [];
        fputcsv($out, ['terms_processed', (string) ((int) ($terms['processed'] ?? 0))]);
        fputcsv($out, ['terms_updated', (string) ((int) ($terms['updated'] ?? 0))]);

        fclose($out);
        exit;
    }

    private function renderJobBox(array $job, bool $autoRun): void
    {
        $stage = (string) ($job['stage'] ?? 'posts');
        $processed = (int) ($job['processed'] ?? 0);
        $total = (int) ($job['total'] ?? 0);

        $pct = $total > 0 ? min(100, (int) floor(($processed / $total) * 100)) : 0;

        echo '<div style="padding:12px;border:1px solid #ccd0d4;background:#fff;margin:12px 0;">';
        echo '<h3 style="margin-top:0;">' . esc_html__('Migration status', 'wp-seo-next') . '</h3>';

        echo '<p><strong>' . esc_html__('Job:', 'wp-seo-next') . '</strong> <code>' . esc_html($job['id']) . '</code></p>';
        echo '<p><strong>' . esc_html__('Source:', 'wp-seo-next') . '</strong> ' . esc_html($job['source']) . '</p>';

        if ($stage === 'posts') {
            echo '<p><strong>' . esc_html__('Posts processed:', 'wp-seo-next') . '</strong> ' . esc_html((string) $processed) . ($total > 0 ? ' / ' . esc_html((string) $total) : '') . '</p>';
            if ($total > 0) {
                echo '<div style="max-width:420px;background:#f1f1f1;border:1px solid #ccd0d4;height:14px;position:relative;">';
                echo '<div style="height:14px;width:' . esc_attr((string) $pct) . '%;background:#2271b1;"></div>';
                echo '</div>';
                echo '<p class="description">' . esc_html(sprintf(__('%d%% complete', 'wp-seo-next'), $pct)) . '</p>';
            }
        } elseif ($stage === 'terms') {
            $tProcessed = (int) ($job['terms']['processed'] ?? 0);
            echo '<p><strong>' . esc_html__('Terms processed:', 'wp-seo-next') . '</strong> ' . esc_html((string) $tProcessed) . '</p>';
        } elseif ($stage === 'done') {
            echo '<p><strong>' . esc_html__('Done.', 'wp-seo-next') . '</strong></p>';
        }

        $counts = $job['counts'] ?? [];
        if (is_array($counts)) {
            $report = $this->buildMigrationReport($job);
            $fields = isset($report['fields']) && is_array($report['fields']) ? $report['fields'] : [];

            echo '<h4 style="margin:14px 0 8px;">' . esc_html__('Migration report', 'wp-seo-next') . '</h4>';
            echo '<p class="description" style="margin-top:0;">' . esc_html__('Imported vs skipped values. Missing source counts mean the source plugin did not have an override set for that item.', 'wp-seo-next') . '</p>';

            echo '<table class="widefat striped" style="max-width:920px;">';
            echo '<thead><tr>';
            echo '<th>' . esc_html__('Field', 'wp-seo-next') . '</th>';
            echo '<th>' . esc_html__('Imported', 'wp-seo-next') . '</th>';
            echo '<th>' . esc_html__('Overwritten', 'wp-seo-next') . '</th>';
            echo '<th>' . esc_html__('Skipped (existing)', 'wp-seo-next') . '</th>';
            echo '<th>' . esc_html__('Missing in source', 'wp-seo-next') . '</th>';
            echo '</tr></thead><tbody>';

            $rows = [
                'title' => __('Title', 'wp-seo-next'),
                'description' => __('Description', 'wp-seo-next'),
                // Best-effort focus keyword import is supported for Yoast/RankMath.
                'focus_keyword' => __('Focus keyword', 'wp-seo-next'),
                'canonical' => __('Canonical', 'wp-seo-next'),
                'robots_index' => __('Robots: index/noindex', 'wp-seo-next'),
                'robots_follow' => __('Robots: follow/nofollow', 'wp-seo-next'),
            ];

            // Hide focus keyword row for sources where we do not support it (yet).
            if (!in_array((string) ($job['source'] ?? ''), ['yoast', 'rankmath'], true)) {
                unset($rows['focus_keyword']);
            }

            if (!empty($job['include_social'])) {
                $rows['og_title'] = __('OpenGraph title', 'wp-seo-next');
                $rows['og_description'] = __('OpenGraph description', 'wp-seo-next');
                $rows['og_image'] = __('OpenGraph image', 'wp-seo-next');
                $rows['twitter_title'] = __('Twitter title', 'wp-seo-next');
                $rows['twitter_description'] = __('Twitter description', 'wp-seo-next');
                $rows['twitter_image'] = __('Twitter image', 'wp-seo-next');
            }

            if (!empty($job['include_schema'])) {
                $rows['schema_type'] = __('Schema type', 'wp-seo-next');
            }
            foreach ($rows as $key => $label) {
                $r = isset($fields[$key]) && is_array($fields[$key]) ? $fields[$key] : [];
                echo '<tr>';
                echo '<td><strong>' . esc_html($label) . '</strong></td>';
                echo '<td>' . esc_html((string) ((int) ($r['imported'] ?? 0))) . '</td>';
                echo '<td>' . esc_html((string) ((int) ($r['overwritten'] ?? 0))) . '</td>';
                echo '<td>' . esc_html((string) ((int) ($r['skipped_existing'] ?? 0))) . '</td>';
                echo '<td>' . esc_html((string) ((int) ($r['missing_source'] ?? 0))) . '</td>';
                echo '</tr>';
            }
            echo '</tbody></table>';

            echo '<p style="margin:10px 0 0;">';
            echo '<strong>' . esc_html__('Posts updated:', 'wp-seo-next') . '</strong> ' . esc_html((string) ((int) ($report['counts']['posts_updated'] ?? 0))) . ' &nbsp; '; 
            echo '<strong>' . esc_html__('Total skipped:', 'wp-seo-next') . '</strong> ' . esc_html((string) ((int) ($report['counts']['skipped_existing_total'] ?? 0)));
            echo '</p>';

            if (!empty($job['include_terms']) && !empty($job['terms']) && is_array($job['terms'])) {
                $tProcessed = (int) ($job['terms']['processed'] ?? 0);
                $tUpdated = (int) ($job['terms']['updated'] ?? 0);
                echo '<p style="margin:6px 0 0;">';
                echo '<strong>' . esc_html__('Terms processed:', 'wp-seo-next') . '</strong> ' . esc_html((string) $tProcessed) . ' &nbsp; ';
                echo '<strong>' . esc_html__('Terms updated:', 'wp-seo-next') . '</strong> ' . esc_html((string) $tUpdated);
                echo '</p>';
            }

            $caps = isset($report['capabilities']) && is_array($report['capabilities']) ? $report['capabilities'] : [];
            echo '<div style="margin-top:12px;">';
            echo '<strong>' . esc_html__('What was migrated', 'wp-seo-next') . '</strong><br/>';
            echo '<span class="description">' . esc_html__('Posts:', 'wp-seo-next') . ' ' . esc_html(implode(', ', (array) ($caps['posts'] ?? []))) . '</span>';
            if (!empty($caps['terms'])) {
                echo '<br/><span class="description">' . esc_html__('Terms:', 'wp-seo-next') . ' ' . esc_html(implode(', ', (array) ($caps['terms'] ?? []))) . '</span>';
            }
            if (!empty($caps['not_supported'])) {
                echo '<br/><strong style="display:inline-block;margin-top:6px;">' . esc_html__('Not migrated', 'wp-seo-next') . '</strong><br/>';
                $pretty = [
                    'social_og_twitter_fields' => __('Social (OG/Twitter) fields', 'wp-seo-next'),
                    'schema_types' => __('Schema types/settings', 'wp-seo-next'),
                    'redirects' => __('Redirect rules', 'wp-seo-next'),
                    'focus_keywords' => __('Focus keywords', 'wp-seo-next'),
                    'readability_scores' => __('Readability/analysis scores', 'wp-seo-next'),
                    'breadcrumbs_settings' => __('Breadcrumbs settings', 'wp-seo-next'),
                ];
                $names = [];
                foreach ((array) $caps['not_supported'] as $slug) {
                    $slug = (string) $slug;
                    $names[] = $pretty[$slug] ?? $slug;
                }
                echo '<span class="description">' . esc_html(implode(', ', $names)) . '</span>';
            }
            echo '</div>';

            // Downloads.
            $dlNonce = wp_create_nonce('wpsn_migration_report_' . (string) $job['id']);
            $dlJson = add_query_arg([
                'action' => 'wpsn_export_migration_report',
                'job' => (string) $job['id'],
                '_wpnonce' => $dlNonce,
            ], admin_url('admin-post.php'));
            $dlCsv = add_query_arg([
                'action' => 'wpsn_export_migration_report_csv',
                'job' => (string) $job['id'],
                '_wpnonce' => $dlNonce,
            ], admin_url('admin-post.php'));

            echo '<p style="margin-top:10px;">';
            echo '<a class="button button-secondary" href="' . esc_url($dlJson) . '">' . esc_html__('Download report (JSON)', 'wp-seo-next') . '</a> ';
            echo '<a class="button button-secondary" href="' . esc_url($dlCsv) . '">' . esc_html__('Download report (CSV)', 'wp-seo-next') . '</a>';
            echo '</p>';
        }

        if ($stage !== 'done') {
            $processUrl = $this->jobProcessUrl($job['id']);
            $cancelUrl  = $this->jobCancelUrl($job['id']);

            echo '<p style="margin-top:12px;">';
            echo '<a class="button button-secondary" href="' . esc_url($processUrl) . '">' . esc_html__('Run next batch', 'wp-seo-next') . '</a> ';
            echo '<a class="button button-link-delete" href="' . esc_url($cancelUrl) . '">' . esc_html__('Cancel', 'wp-seo-next') . '</a>';
            if (!$autoRun) {
                echo ' <span class="description">' . esc_html__('Tip: start with "Run next batch" if you prefer manual progress.', 'wp-seo-next') . '</span>';
            }
            echo '</p>';
        }

        echo '</div>';
    }

    private function processJob(array &$job): void
    {
        $stage = (string) ($job['stage'] ?? 'posts');

        if ($stage === 'posts') {
            $this->processPostsStage($job);
            // fallthrough if posts finished and terms not needed.
        }

        if (($job['stage'] ?? '') === 'terms') {
            $this->processTermsStage($job);
        }

        if (($job['stage'] ?? '') !== 'posts' && ($job['stage'] ?? '') !== 'terms') {
            $job['stage'] = 'done';
        }
    }

    private function processPostsStage(array &$job): void
    {
        $postTypes = is_array($job['post_types'] ?? null) ? $job['post_types'] : [];
        $postTypes = array_values(array_filter(array_map('sanitize_key', $postTypes)));
        if (empty($postTypes)) {
            $job['stage'] = 'done';
            return;
        }

        $lastId = (int) ($job['last_id'] ?? 0);
        $limit  = (int) ($job['batch'] ?? self::BATCH_SIZE);
        if ($limit <= 0) {
            $limit = self::BATCH_SIZE;
        }

        $ids = $this->getPostIdsBatch($postTypes, $lastId, $limit);

        if (empty($ids)) {
            // Posts done.
            $source = (string) ($job['source'] ?? 'yoast');
            if (!empty($job['include_terms']) && in_array($source, ['yoast', 'seopress'], true)) {
                $job['stage'] = 'terms';
            } else {
                $job['stage'] = 'done';
            }
            return;
        }

        // Prime meta cache.
        update_meta_cache('post', $ids);

        $source = (string) ($job['source'] ?? 'yoast');
        $overwrite = !empty($job['overwrite']);
        $includeSocial = !empty($job['include_social']);
        $includeSchema = !empty($job['include_schema']);

        // Optional AIOSEO prefetch map.
        $aioseoMap = null;
        if ($source === 'aioseo') {
            $aioseoMap = $this->prefetchAioseoRows($ids);
        }

        foreach ($ids as $postId) {
            $postId = (int) $postId;
            if ($postId <= 0) {
                continue;
            }

            $result = $this->importForPost($source, $postId, $overwrite, $aioseoMap, $includeSocial, $includeSchema);
            if (!is_array($result)) {
                continue;
            }

            $job['processed'] = (int) ($job['processed'] ?? 0) + 1;
            $job['last_id'] = $postId;

            // Aggregate counts.
            if (!empty($result['updated'])) {
                $job['counts']['posts_updated']++;
            }
            foreach ($result as $k => $v) {
                if ($k === 'updated') {
                    continue;
                }
                if (is_int($v) || (is_numeric($v) && (string) (int) $v === (string) $v)) {
                    $job['counts'][$k] = (int) ($job['counts'][$k] ?? 0) + (int) $v;
                }
            }
        }

        // If we processed fewer than batch, consider posts complete on next tick.
        if (count($ids) < $limit) {
            // Let next run advance stage (avoids edge cases).
        }
    }

    private function processTermsStage(array &$job): void
    {
        $source = (string) ($job['source'] ?? 'yoast');

        // SEOPress termmeta migration.
        if ($source === 'seopress') {
            $this->processSeopressTermsStage($job);
            return;
        }

        if ($source !== 'yoast') {
            $job['stage'] = 'done';
            return;
        }

        // Yoast taxonomy meta.
        $taxMeta = get_option('wpseo_taxonomy_meta', null);
        if (!is_array($taxMeta) || empty($taxMeta)) {
            $job['stage'] = 'done';
            return;
        }

        $overwrite = !empty($job['overwrite']);
        $includeSocial = !empty($job['include_social']);
        $limit = (int) ($job['batch'] ?? self::BATCH_SIZE);
        if ($limit <= 0) {
            $limit = self::BATCH_SIZE;
        }

        $taxonomies = array_keys($taxMeta);
        sort($taxonomies);

        $lastTax  = (string) ($job['terms']['last_tax'] ?? '');
        $lastTerm = (int) ($job['terms']['last_term'] ?? 0);

        $started = false;
        $processedThisRun = 0;

        foreach ($taxonomies as $tax) {
            if (!taxonomy_exists($tax)) {
                continue;
            }

            // Skip until we reach last taxonomy (if set).
            if ($lastTax !== '' && !$started) {
                if ($tax === $lastTax) {
                    $started = true;
                } else {
                    continue;
                }
            } else {
                $started = true;
            }

            $items = $taxMeta[$tax];
            if (!is_array($items)) {
                $lastTerm = 0;
                $job['terms']['last_tax'] = $tax;
                continue;
            }

            $termIds = [];
            foreach (array_keys($items) as $tid) {
                $tid = (int) $tid;
                if ($tid > 0) {
                    $termIds[] = $tid;
                }
            }
            sort($termIds);

            foreach ($termIds as $termId) {
                if ($tax === $lastTax && $termId <= $lastTerm) {
                    continue;
                }

                $meta = $items[(string) $termId] ?? $items[$termId] ?? null;
                if (!is_array($meta)) {
                    $meta = [];
                }

                $r = $this->importYoastTerm($termId, $tax, $meta, $overwrite, $includeSocial);
                $job['terms']['processed'] = (int) ($job['terms']['processed'] ?? 0) + 1;
                $processedThisRun++;

                if (!empty($r['updated'])) {
                    $job['terms']['updated'] = (int) ($job['terms']['updated'] ?? 0) + 1;
                }

                // Track cursor.
                $job['terms']['last_tax']  = $tax;
                $job['terms']['last_term'] = $termId;

                if ($processedThisRun >= $limit) {
                    return;
                }
            }

            // Finished this taxonomy.
            $lastTerm = 0;
            $job['terms']['last_tax']  = $tax;
            $job['terms']['last_term'] = 0;
        }

        // All done.
        $job['stage'] = 'done';
    }

    private function processSeopressTermsStage(array &$job): void
    {
        $overwrite = !empty($job['overwrite']);
        $includeSocial = !empty($job['include_social']);

        $limit = (int) ($job['batch'] ?? self::BATCH_SIZE);
        if ($limit <= 0) {
            $limit = self::BATCH_SIZE;
        }

        $lastTerm = (int) ($job['terms']['last_term'] ?? 0);
        $ids = $this->getSeopressTermIdsBatch($lastTerm, $limit);
        if (empty($ids)) {
            $job['stage'] = 'done';
            return;
        }

        // Prime term meta cache.
        update_meta_cache('term', $ids);

        foreach ($ids as $termId) {
            $termId = (int) $termId;
            if ($termId <= 0) {
                continue;
            }

            // Skip deleted terms.
            $t = get_term($termId);
            if (!$t || is_wp_error($t)) {
                $job['terms']['last_term'] = $termId;
                continue;
            }

            $r = $this->importSeopressTerm($termId, $overwrite, $includeSocial);
            $job['terms']['processed'] = (int) ($job['terms']['processed'] ?? 0) + 1;

            if (!empty($r['updated'])) {
                $job['terms']['updated'] = (int) ($job['terms']['updated'] ?? 0) + 1;
            }

            $job['terms']['last_tax'] = '';
            $job['terms']['last_term'] = $termId;
        }

        // If we processed fewer than batch, next run will complete.
    }

    /**
     * @return array<string, mixed>|null
     */
    private function prefetchAioseoRows(array $postIds): ?array
    {
        global $wpdb;

        $table = $wpdb->prefix . 'aioseo_posts';
        $exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)));
        if (!is_string($exists) || $exists !== $table) {
            return null;
        }

        $postIds = array_values(array_filter(array_map('intval', $postIds)));
        if (empty($postIds)) {
            return null;
        }

        // Build a safe column list (AIOSEO columns vary between versions).
        $cols = $wpdb->get_col("SHOW COLUMNS FROM {$table}");
        $cols = is_array($cols) ? array_map('strval', $cols) : [];

        $baseCols = ['post_id', 'title', 'description', 'canonical_url', 'robots_default', 'robots_noindex', 'robots_nofollow'];
        $optional = [
            // Social
            'og_title',
            'og_description',
            'og_image_url',
            'og_image_custom_url',
            'twitter_title',
            'twitter_description',
            'twitter_image_url',
            'twitter_image_custom_url',
            // Schema
            'schema_type',
        ];

        $selectCols = [];
        foreach (array_merge($baseCols, $optional) as $c) {
            if (in_array($c, $cols, true)) {
                $selectCols[] = $c;
            }
        }

        // Fallback to base columns if column discovery failed.
        if (empty($selectCols)) {
            $selectCols = $baseCols;
        }

        $placeholders = implode(',', array_fill(0, count($postIds), '%d'));
        $safeCols = array_values(array_filter(array_map(static function (string $c): string {
            $c = preg_replace('/[^a-z0-9_]/i', '', $c);
            return is_string($c) ? $c : '';
        }, $selectCols), static function (string $c): bool {
            return $c !== '';
        }));

        if (empty($safeCols)) {
            $safeCols = $baseCols;
        }

        $sql = 'SELECT ' . implode(', ', $safeCols) . " FROM {$table} WHERE post_id IN ({$placeholders})";

        $rows = $wpdb->get_results($wpdb->prepare($sql, $postIds), ARRAY_A);

        if (!is_array($rows)) {
            return null;
        }

        $map = [];
        foreach ($rows as $row) {
            if (!is_array($row) || empty($row['post_id'])) {
                continue;
            }
            $pid = (int) $row['post_id'];
            $map[$pid] = $row;
        }

        return $map;
    }

    /**
     * @param array<string, mixed>|null $aioseoMap
     * @return array<string, int|bool>
     */
    private function importForPost(string $source, int $postId, bool $overwrite, ?array $aioseoMap = null, bool $includeSocial = true, bool $includeSchema = true): array
    {
        switch ($source) {
            case 'rankmath':
                return $this->importRankMathPost($postId, $overwrite, $includeSocial, $includeSchema);
            case 'seopress':
                return $this->importSeopressPost($postId, $overwrite, $includeSocial, $includeSchema);
            case 'aioseo':
                return $this->importAioseoPost($postId, $overwrite, $aioseoMap, $includeSocial, $includeSchema);
            case 'yoast':
            default:
                return $this->importYoastPost($postId, $overwrite, $includeSocial, $includeSchema);
        }
    }

    /**
     * @return array<string, int|bool>
     */
    private function importYoastPost(int $postId, bool $overwrite, bool $includeSocial = true, bool $includeSchema = true): array
    {
        $counts = [
            'updated'         => false,
            'titles'          => 0,
            'titles_overwritten' => 0,
            'titles_skipped_existing' => 0,
            'titles_missing_source' => 0,
            'descriptions'    => 0,
            'descriptions_overwritten' => 0,
            'descriptions_skipped_existing' => 0,
            'descriptions_missing_source' => 0,
            'focus_keywords'    => 0,
            'focus_keywords_overwritten' => 0,
            'focus_keywords_skipped_existing' => 0,
            'focus_keywords_missing_source' => 0,
            'canonicals'      => 0,
            'canonicals_overwritten' => 0,
            'canonicals_skipped_existing' => 0,
            'canonicals_missing_source' => 0,
            'robots_index'    => 0,
            'robots_index_overwritten' => 0,
            'robots_index_skipped_existing' => 0,
            'robots_index_missing_source' => 0,
            'robots_follow'   => 0,
            'robots_follow_overwritten' => 0,
            'robots_follow_skipped_existing' => 0,
            'robots_follow_missing_source' => 0,
            'og_titles' => 0,
            'og_titles_overwritten' => 0,
            'og_titles_skipped_existing' => 0,
            'og_titles_missing_source' => 0,
            'og_descriptions' => 0,
            'og_descriptions_overwritten' => 0,
            'og_descriptions_skipped_existing' => 0,
            'og_descriptions_missing_source' => 0,
            'og_images' => 0,
            'og_images_overwritten' => 0,
            'og_images_skipped_existing' => 0,
            'og_images_missing_source' => 0,
            'twitter_titles' => 0,
            'twitter_titles_overwritten' => 0,
            'twitter_titles_skipped_existing' => 0,
            'twitter_titles_missing_source' => 0,
            'twitter_descriptions' => 0,
            'twitter_descriptions_overwritten' => 0,
            'twitter_descriptions_skipped_existing' => 0,
            'twitter_descriptions_missing_source' => 0,
            'twitter_images' => 0,
            'twitter_images_overwritten' => 0,
            'twitter_images_skipped_existing' => 0,
            'twitter_images_missing_source' => 0,
            'schema_types' => 0,
            'schema_types_overwritten' => 0,
            'schema_types_skipped_existing' => 0,
            'schema_types_missing_source' => 0,
            'skipped_existing'=> 0,
        ];

        $title = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_title']);
        $desc  = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_metadesc']);
        $focus = $this->getFirstPostMetaStringByKeys($postId, [
            '_yoast_wpseo_focuskw',
            '_yoast_wpseo_focuskw_text_input',
            '_yoast_wpseo_focuskeywords',
            '_yoast_wpseo_focuskeywords_text_input',
        ]);
        $canon = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_canonical']);

        $noindex = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_meta-robots-noindex', '_yoast_wpseo_meta_robots_noindex']);
        $nofollow = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_meta-robots-nofollow', '_yoast_wpseo_meta_robots_nofollow']);

        $title = $this->convertYoastVars($this->cleanText($title));
        $desc  = $this->convertYoastVars($this->cleanText($desc));
        $focus = $this->cleanText($focus);
        if (strpos($focus, ',') !== false) {
            $parts = array_filter(array_map('trim', explode(',', $focus)), static function ($v): bool {
                return $v !== '';
            });
            $focus = !empty($parts) ? (string) reset($parts) : '';
        }
        $canon = $this->cleanUrl($canon);

        if ($title === '') {
            $counts['titles_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::TITLE, $title, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['titles'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['titles_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['titles_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        if ($desc === '') {
            $counts['descriptions_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::DESCRIPTION, $desc, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['descriptions'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['descriptions_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['descriptions_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        // Focus keyword (best-effort).
        if ($focus === '') {
            $counts['focus_keywords_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::FOCUS_KEYWORD, $focus, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['focus_keywords'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['focus_keywords_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['focus_keywords_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        if ($canon === '') {
            $counts['canonicals_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::CANONICAL, $canon, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['canonicals'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['canonicals_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['canonicals_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        $rIndex = $this->parseYoastRobotsIndex($noindex);
        if ($rIndex === '') {
            $counts['robots_index_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::ROBOTS_INDEX, $rIndex, $overwrite, true);
            if (!empty($o['updated'])) {
                $counts['robots_index'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['robots_index_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['robots_index_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        $rFollow = $this->parseYoastRobotsFollow($nofollow);
        if ($rFollow === '') {
            $counts['robots_follow_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::ROBOTS_FOLLOW, $rFollow, $overwrite, true);
            if (!empty($o['updated'])) {
                $counts['robots_follow'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['robots_follow_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['robots_follow_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        // Social (OpenGraph/Twitter)
        if ($includeSocial) {
            $ogTitle = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_opengraph-title', '_yoast_wpseo_opengraph_title']);
            $ogDesc  = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_opengraph-description', '_yoast_wpseo_opengraph_description']);
            $ogImg   = $this->getFirstPostMetaImageUrlByKeys($postId, [
                '_yoast_wpseo_opengraph-image',
                '_yoast_wpseo_opengraph_image',
                '_yoast_wpseo_opengraph-image-url',
                '_yoast_wpseo_opengraph_image_url',
                '_yoast_wpseo_opengraph-image-meta',
                '_yoast_wpseo_opengraph_image_meta',
                '_yoast_wpseo_opengraph-image_meta',
                '_yoast_wpseo_opengraph_image-meta',
                '_yoast_wpseo_opengraph-image-id',
                '_yoast_wpseo_opengraph-image_id',
                '_yoast_wpseo_opengraph_image_id',
            ]);

            $twTitle = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_twitter-title', '_yoast_wpseo_twitter_title']);
            $twDesc  = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_twitter-description', '_yoast_wpseo_twitter_description']);
            $twImg   = $this->getFirstPostMetaImageUrlByKeys($postId, [
                '_yoast_wpseo_twitter-image',
                '_yoast_wpseo_twitter_image',
                '_yoast_wpseo_twitter-image-url',
                '_yoast_wpseo_twitter_image_url',
                '_yoast_wpseo_twitter-image-meta',
                '_yoast_wpseo_twitter_image_meta',
                '_yoast_wpseo_twitter-image_meta',
                '_yoast_wpseo_twitter_image-meta',
                '_yoast_wpseo_twitter-image-id',
                '_yoast_wpseo_twitter-image_id',
                '_yoast_wpseo_twitter_image_id',
            ]);

            $ogTitle = $this->convertYoastVars($this->cleanText($ogTitle));
            $ogDesc  = $this->convertYoastVars($this->cleanText($ogDesc));

            $twTitle = $this->convertYoastVars($this->cleanText($twTitle));
            $twDesc  = $this->convertYoastVars($this->cleanText($twDesc));

            // OG title
            if ($ogTitle === '') {
                $counts['og_titles_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_TITLE, $ogTitle, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_titles'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_titles_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_titles_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            // OG description
            if ($ogDesc === '') {
                $counts['og_descriptions_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_DESCRIPTION, $ogDesc, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_descriptions'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_descriptions_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_descriptions_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            // OG image
            if ($ogImg === '') {
                $counts['og_images_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_IMAGE, $ogImg, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_images'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_images_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_images_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            // Twitter title
            if ($twTitle === '') {
                $counts['twitter_titles_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_TITLE, $twTitle, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_titles'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_titles_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_titles_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            // Twitter description
            if ($twDesc === '') {
                $counts['twitter_descriptions_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_DESCRIPTION, $twDesc, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_descriptions'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_descriptions_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_descriptions_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            // Twitter image
            if ($twImg === '') {
                $counts['twitter_images_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_IMAGE, $twImg, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_images'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_images_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_images_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }
        }

        // Schema type
        if ($includeSchema) {
            $pageType = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_schema_page_type', '_yoast_wpseo_schema-page-type']);
            $articleType = $this->getFirstPostMetaStringByKeys($postId, ['_yoast_wpseo_schema_article_type', '_yoast_wpseo_schema-article-type']);
            $schema = $this->mapSchemaTypeFromYoast($pageType, $articleType, (string) get_post_type($postId));

            if ($schema === '') {
                $counts['schema_types_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::SCHEMA_TYPE, $schema, $overwrite, true);
                if (!empty($o['updated'])) {
                    $counts['schema_types'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['schema_types_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['schema_types_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }
        }

        return $counts;
    }

    /**
     * @return array<string, int|bool>
     */
    private function importRankMathPost(int $postId, bool $overwrite, bool $includeSocial = true, bool $includeSchema = true): array
    {
        $counts = [
            'updated'         => false,
            'titles'          => 0,
            'titles_overwritten' => 0,
            'titles_skipped_existing' => 0,
            'titles_missing_source' => 0,
            'descriptions'    => 0,
            'descriptions_overwritten' => 0,
            'descriptions_skipped_existing' => 0,
            'descriptions_missing_source' => 0,
            'focus_keywords'    => 0,
            'focus_keywords_overwritten' => 0,
            'focus_keywords_skipped_existing' => 0,
            'focus_keywords_missing_source' => 0,
            'canonicals'      => 0,
            'canonicals_overwritten' => 0,
            'canonicals_skipped_existing' => 0,
            'canonicals_missing_source' => 0,
            'robots_index'    => 0,
            'robots_index_overwritten' => 0,
            'robots_index_skipped_existing' => 0,
            'robots_index_missing_source' => 0,
            'robots_follow'   => 0,
            'robots_follow_overwritten' => 0,
            'robots_follow_skipped_existing' => 0,
            'robots_follow_missing_source' => 0,
            'og_titles' => 0,
            'og_titles_overwritten' => 0,
            'og_titles_skipped_existing' => 0,
            'og_titles_missing_source' => 0,
            'og_descriptions' => 0,
            'og_descriptions_overwritten' => 0,
            'og_descriptions_skipped_existing' => 0,
            'og_descriptions_missing_source' => 0,
            'og_images' => 0,
            'og_images_overwritten' => 0,
            'og_images_skipped_existing' => 0,
            'og_images_missing_source' => 0,
            'twitter_titles' => 0,
            'twitter_titles_overwritten' => 0,
            'twitter_titles_skipped_existing' => 0,
            'twitter_titles_missing_source' => 0,
            'twitter_descriptions' => 0,
            'twitter_descriptions_overwritten' => 0,
            'twitter_descriptions_skipped_existing' => 0,
            'twitter_descriptions_missing_source' => 0,
            'twitter_images' => 0,
            'twitter_images_overwritten' => 0,
            'twitter_images_skipped_existing' => 0,
            'twitter_images_missing_source' => 0,
            'schema_types' => 0,
            'schema_types_overwritten' => 0,
            'schema_types_skipped_existing' => 0,
            'schema_types_missing_source' => 0,
            'skipped_existing'=> 0,
        ];

        $title = (string) get_post_meta($postId, 'rank_math_title', true);
        $desc  = (string) get_post_meta($postId, 'rank_math_description', true);
        $focus = (string) get_post_meta($postId, 'rank_math_focus_keyword', true);
        $canon = (string) get_post_meta($postId, 'rank_math_canonical_url', true);
        $robots = get_post_meta($postId, 'rank_math_robots', true);

        $title = $this->cleanText($title);
        $desc  = $this->cleanText($desc);
        $focus = $this->cleanText($focus);
        // RankMath can store multiple keywords separated by commas.
        if (strpos($focus, ',') !== false) {
            $parts = array_filter(array_map('trim', explode(',', $focus)), static function ($v): bool {
                return $v !== '';
            });
            $focus = !empty($parts) ? (string) reset($parts) : '';
        }
        $canon = $this->cleanUrl($canon);

        if ($title === '') {
            $counts['titles_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::TITLE, $title, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['titles'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['titles_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['titles_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        if ($desc === '') {
            $counts['descriptions_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::DESCRIPTION, $desc, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['descriptions'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['descriptions_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['descriptions_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        // Focus keyword (best-effort).
        if ($focus === '') {
            $counts['focus_keywords_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::FOCUS_KEYWORD, $focus, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['focus_keywords'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['focus_keywords_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['focus_keywords_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        if ($canon === '') {
            $counts['canonicals_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::CANONICAL, $canon, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['canonicals'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['canonicals_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['canonicals_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        [$rIndex, $rFollow] = $this->parseRankMathRobots($robots);

        if ($rIndex === '') {
            $counts['robots_index_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::ROBOTS_INDEX, $rIndex, $overwrite, true);
            if (!empty($o['updated'])) {
                $counts['robots_index'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['robots_index_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['robots_index_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        if ($rFollow === '') {
            $counts['robots_follow_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::ROBOTS_FOLLOW, $rFollow, $overwrite, true);
            if (!empty($o['updated'])) {
                $counts['robots_follow'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['robots_follow_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['robots_follow_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        // Social (OpenGraph/Twitter)
        if ($includeSocial) {
            $ogTitle = (string) get_post_meta($postId, 'rank_math_facebook_title', true);
            $ogDesc  = (string) get_post_meta($postId, 'rank_math_facebook_description', true);
            $ogImg   = (string) get_post_meta($postId, 'rank_math_facebook_image', true);
            $ogImgId = get_post_meta($postId, 'rank_math_facebook_image_id', true);

            $twTitle = (string) get_post_meta($postId, 'rank_math_twitter_title', true);
            $twDesc  = (string) get_post_meta($postId, 'rank_math_twitter_description', true);
            $twImg   = (string) get_post_meta($postId, 'rank_math_twitter_image', true);
            $twImgId = get_post_meta($postId, 'rank_math_twitter_image_id', true);

            $ogTitle = $this->cleanText($ogTitle);
            $ogDesc  = $this->cleanText($ogDesc);
            $ogImg   = $this->resolveImageUrl($this->cleanUrl($ogImg), $ogImgId);

            $twTitle = $this->cleanText($twTitle);
            $twDesc  = $this->cleanText($twDesc);
            $twImg   = $this->resolveImageUrl($this->cleanUrl($twImg), $twImgId);

            if ($ogTitle === '') {
                $counts['og_titles_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_TITLE, $ogTitle, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_titles'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_titles_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_titles_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($ogDesc === '') {
                $counts['og_descriptions_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_DESCRIPTION, $ogDesc, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_descriptions'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_descriptions_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_descriptions_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($ogImg === '') {
                $counts['og_images_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_IMAGE, $ogImg, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_images'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_images_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_images_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($twTitle === '') {
                $counts['twitter_titles_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_TITLE, $twTitle, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_titles'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_titles_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_titles_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($twDesc === '') {
                $counts['twitter_descriptions_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_DESCRIPTION, $twDesc, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_descriptions'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_descriptions_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_descriptions_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($twImg === '') {
                $counts['twitter_images_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_IMAGE, $twImg, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_images'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_images_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_images_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }
        }

        // Schema type
        if ($includeSchema) {
            $rmType = (string) get_post_meta($postId, 'rank_math_schema_type', true);
            if ($rmType === '') {
                $rmType = (string) get_post_meta($postId, 'rank_math_rich_snippet', true);
            }

            $schema = $this->mapSchemaTypeFromRankMath($rmType, (string) get_post_type($postId));
            if ($schema === '') {
                $counts['schema_types_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::SCHEMA_TYPE, $schema, $overwrite, true);
                if (!empty($o['updated'])) {
                    $counts['schema_types'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['schema_types_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['schema_types_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }
        }

        return $counts;
    }

    /**
     * @param array<string,mixed>|null $aioseoMap
     * @return array<string, int|bool>
     */
    private function importAioseoPost(int $postId, bool $overwrite, ?array $aioseoMap = null, bool $includeSocial = true, bool $includeSchema = true): array
    {
        $counts = [
            'updated'         => false,
            'titles'          => 0,
            'titles_overwritten' => 0,
            'titles_skipped_existing' => 0,
            'titles_missing_source' => 0,
            'descriptions'    => 0,
            'descriptions_overwritten' => 0,
            'descriptions_skipped_existing' => 0,
            'descriptions_missing_source' => 0,
            'focus_keywords'    => 0,
            'focus_keywords_overwritten' => 0,
            'focus_keywords_skipped_existing' => 0,
            'focus_keywords_missing_source' => 0,
            'canonicals'      => 0,
            'canonicals_overwritten' => 0,
            'canonicals_skipped_existing' => 0,
            'canonicals_missing_source' => 0,
            'robots_index'    => 0,
            'robots_index_overwritten' => 0,
            'robots_index_skipped_existing' => 0,
            'robots_index_missing_source' => 0,
            'robots_follow'   => 0,
            'robots_follow_overwritten' => 0,
            'robots_follow_skipped_existing' => 0,
            'robots_follow_missing_source' => 0,
            'og_titles' => 0,
            'og_titles_overwritten' => 0,
            'og_titles_skipped_existing' => 0,
            'og_titles_missing_source' => 0,
            'og_descriptions' => 0,
            'og_descriptions_overwritten' => 0,
            'og_descriptions_skipped_existing' => 0,
            'og_descriptions_missing_source' => 0,
            'og_images' => 0,
            'og_images_overwritten' => 0,
            'og_images_skipped_existing' => 0,
            'og_images_missing_source' => 0,
            'twitter_titles' => 0,
            'twitter_titles_overwritten' => 0,
            'twitter_titles_skipped_existing' => 0,
            'twitter_titles_missing_source' => 0,
            'twitter_descriptions' => 0,
            'twitter_descriptions_overwritten' => 0,
            'twitter_descriptions_skipped_existing' => 0,
            'twitter_descriptions_missing_source' => 0,
            'twitter_images' => 0,
            'twitter_images_overwritten' => 0,
            'twitter_images_skipped_existing' => 0,
            'twitter_images_missing_source' => 0,
            'schema_types' => 0,
            'schema_types_overwritten' => 0,
            'schema_types_skipped_existing' => 0,
            'schema_types_missing_source' => 0,
            'skipped_existing'=> 0,
        ];

        // AIOSEO v4+ table row (preferred if available).
        $row = is_array($aioseoMap) && isset($aioseoMap[$postId]) && is_array($aioseoMap[$postId]) ? $aioseoMap[$postId] : null;

        $title = '';
        $desc  = '';
        $canon = '';
        $robotsDefault = null;
        $robotsNoindex = null;
        $robotsNofollow = null;

        // Social/schema best-effort
        $ogTitle = '';
        $ogDesc = '';
        $ogImg = '';
        $twTitle = '';
        $twDesc = '';
        $twImg = '';
        $schemaTypeRaw = '';

        if (is_array($row)) {
            $title = isset($row['title']) ? (string) $row['title'] : '';
            $desc  = isset($row['description']) ? (string) $row['description'] : '';
            $canon = isset($row['canonical_url']) ? (string) $row['canonical_url'] : '';

            $robotsDefault = $row['robots_default'] ?? null;
            $robotsNoindex = $row['robots_noindex'] ?? null;
            $robotsNofollow = $row['robots_nofollow'] ?? null;

            if ($includeSocial) {
                $ogTitle = isset($row['og_title']) ? (string) $row['og_title'] : (isset($row['ogTitle']) ? (string) $row['ogTitle'] : '');
                $ogDesc  = isset($row['og_description']) ? (string) $row['og_description'] : (isset($row['ogDescription']) ? (string) $row['ogDescription'] : '');
                $ogImg   = isset($row['og_image_url']) ? (string) $row['og_image_url'] : (isset($row['og_image_custom_url']) ? (string) $row['og_image_custom_url'] : (isset($row['ogImageUrl']) ? (string) $row['ogImageUrl'] : ''));

                $twTitle = isset($row['twitter_title']) ? (string) $row['twitter_title'] : (isset($row['twitterTitle']) ? (string) $row['twitterTitle'] : '');
                $twDesc  = isset($row['twitter_description']) ? (string) $row['twitter_description'] : (isset($row['twitterDescription']) ? (string) $row['twitterDescription'] : '');
                $twImg   = isset($row['twitter_image_url']) ? (string) $row['twitter_image_url'] : (isset($row['twitter_image_custom_url']) ? (string) $row['twitter_image_custom_url'] : (isset($row['twitterImageUrl']) ? (string) $row['twitterImageUrl'] : ''));
            }

            if ($includeSchema) {
                $schemaTypeRaw = isset($row['schema_type']) ? (string) $row['schema_type'] : (isset($row['schemaType']) ? (string) $row['schemaType'] : '');
            }
        } else {
            // Fallback: meta keys (best-effort) for AIOSEO.
            $title = (string) get_post_meta($postId, '_aioseo_title', true);
            $desc  = (string) get_post_meta($postId, '_aioseo_description', true);
            $canon = (string) get_post_meta($postId, '_aioseo_canonical_url', true);

            // Old All In One SEO Pack meta keys.
            if ($title === '') {
                $title = (string) get_post_meta($postId, '_aioseop_title', true);
            }
            if ($desc === '') {
                $desc = (string) get_post_meta($postId, '_aioseop_description', true);
            }
            if ($canon === '') {
                $canon = (string) get_post_meta($postId, '_aioseop_custom_link', true);
            }

            $oldNoindex = (string) get_post_meta($postId, '_aioseop_noindex', true);
            $oldNofollow = (string) get_post_meta($postId, '_aioseop_nofollow', true);

            if ($oldNoindex !== '') {
                $robotsNoindex = $oldNoindex;
                $robotsDefault = 0;
            }
            if ($oldNofollow !== '') {
                $robotsNofollow = $oldNofollow;
                $robotsDefault = 0;
            }

            if ($includeSocial) {
                // AIOSEO v4 meta keys (best effort).
                $ogTitle = (string) get_post_meta($postId, '_aioseo_og_title', true);
                $ogDesc  = (string) get_post_meta($postId, '_aioseo_og_description', true);
                $ogImg   = (string) get_post_meta($postId, '_aioseo_og_image', true);
                if ($ogImg === '') {
                    $ogImg = (string) get_post_meta($postId, '_aioseo_og_image_url', true);
                }

                $twTitle = (string) get_post_meta($postId, '_aioseo_twitter_title', true);
                $twDesc  = (string) get_post_meta($postId, '_aioseo_twitter_description', true);
                $twImg   = (string) get_post_meta($postId, '_aioseo_twitter_image', true);
                if ($twImg === '') {
                    $twImg = (string) get_post_meta($postId, '_aioseo_twitter_image_url', true);
                }
            }

            if ($includeSchema) {
                $schemaTypeRaw = (string) get_post_meta($postId, '_aioseo_schema_type', true);
                if ($schemaTypeRaw === '') {
                    $schemaTypeRaw = (string) get_post_meta($postId, '_aioseop_schema_type', true);
                }
            }
        }

        $title = $this->cleanText($title);
        $desc  = $this->cleanText($desc);
        $canon = $this->cleanUrl($canon);

        $ogTitle = $this->cleanText($ogTitle);
        $ogDesc  = $this->cleanText($ogDesc);
        $ogImg   = $this->cleanUrl($ogImg);
        $twTitle = $this->cleanText($twTitle);
        $twDesc  = $this->cleanText($twDesc);
        $twImg   = $this->cleanUrl($twImg);

        if ($title === '') {
            $counts['titles_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::TITLE, $title, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['titles'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['titles_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['titles_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        if ($desc === '') {
            $counts['descriptions_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::DESCRIPTION, $desc, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['descriptions'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['descriptions_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['descriptions_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        if ($canon === '') {
            $counts['canonicals_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::CANONICAL, $canon, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['canonicals'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['canonicals_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['canonicals_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        // Robots (table values are booleans/ints; old meta uses 'on').
        [$rIndex, $rFollow] = $this->parseAioseoRobots($robotsDefault, $robotsNoindex, $robotsNofollow);

        if ($rIndex === '') {
            $counts['robots_index_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::ROBOTS_INDEX, $rIndex, $overwrite, true);
            if (!empty($o['updated'])) {
                $counts['robots_index'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['robots_index_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['robots_index_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        if ($rFollow === '') {
            $counts['robots_follow_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::ROBOTS_FOLLOW, $rFollow, $overwrite, true);
            if (!empty($o['updated'])) {
                $counts['robots_follow'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['robots_follow_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['robots_follow_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        // Social (OpenGraph/Twitter)
        if ($includeSocial) {
            if ($ogTitle === '') {
                $counts['og_titles_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_TITLE, $ogTitle, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_titles'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_titles_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_titles_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($ogDesc === '') {
                $counts['og_descriptions_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_DESCRIPTION, $ogDesc, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_descriptions'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_descriptions_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_descriptions_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($ogImg === '') {
                $counts['og_images_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_IMAGE, $ogImg, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_images'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_images_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_images_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($twTitle === '') {
                $counts['twitter_titles_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_TITLE, $twTitle, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_titles'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_titles_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_titles_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($twDesc === '') {
                $counts['twitter_descriptions_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_DESCRIPTION, $twDesc, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_descriptions'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_descriptions_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_descriptions_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($twImg === '') {
                $counts['twitter_images_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_IMAGE, $twImg, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_images'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_images_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_images_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }
        }

        // Schema type
        if ($includeSchema) {
            $schema = $this->mapSchemaTypeFromAioseo($schemaTypeRaw, (string) get_post_type($postId));
            if ($schema === '') {
                $counts['schema_types_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::SCHEMA_TYPE, $schema, $overwrite, true);
                if (!empty($o['updated'])) {
                    $counts['schema_types'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['schema_types_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['schema_types_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }
        }

        return $counts;
    }

    /**
     * @return array<string, int|bool>
     */
    private function importSeopressPost(int $postId, bool $overwrite, bool $includeSocial = true, bool $includeSchema = true): array
    {
        $counts = [
            'updated'         => false,
            'titles'          => 0,
            'titles_overwritten' => 0,
            'titles_skipped_existing' => 0,
            'titles_missing_source' => 0,
            'descriptions'    => 0,
            'descriptions_overwritten' => 0,
            'descriptions_skipped_existing' => 0,
            'descriptions_missing_source' => 0,
            'focus_keywords'    => 0,
            'focus_keywords_overwritten' => 0,
            'focus_keywords_skipped_existing' => 0,
            'focus_keywords_missing_source' => 0,
            'canonicals'      => 0,
            'canonicals_overwritten' => 0,
            'canonicals_skipped_existing' => 0,
            'canonicals_missing_source' => 0,
            'robots_index'    => 0,
            'robots_index_overwritten' => 0,
            'robots_index_skipped_existing' => 0,
            'robots_index_missing_source' => 0,
            'robots_follow'   => 0,
            'robots_follow_overwritten' => 0,
            'robots_follow_skipped_existing' => 0,
            'robots_follow_missing_source' => 0,
            'og_titles' => 0,
            'og_titles_overwritten' => 0,
            'og_titles_skipped_existing' => 0,
            'og_titles_missing_source' => 0,
            'og_descriptions' => 0,
            'og_descriptions_overwritten' => 0,
            'og_descriptions_skipped_existing' => 0,
            'og_descriptions_missing_source' => 0,
            'og_images' => 0,
            'og_images_overwritten' => 0,
            'og_images_skipped_existing' => 0,
            'og_images_missing_source' => 0,
            'twitter_titles' => 0,
            'twitter_titles_overwritten' => 0,
            'twitter_titles_skipped_existing' => 0,
            'twitter_titles_missing_source' => 0,
            'twitter_descriptions' => 0,
            'twitter_descriptions_overwritten' => 0,
            'twitter_descriptions_skipped_existing' => 0,
            'twitter_descriptions_missing_source' => 0,
            'twitter_images' => 0,
            'twitter_images_overwritten' => 0,
            'twitter_images_skipped_existing' => 0,
            'twitter_images_missing_source' => 0,
            'schema_types' => 0,
            'schema_types_overwritten' => 0,
            'schema_types_skipped_existing' => 0,
            'schema_types_missing_source' => 0,
            'skipped_existing'=> 0,
        ];

        $title = (string) get_post_meta($postId, '_seopress_titles_title', true);
        $desc  = (string) get_post_meta($postId, '_seopress_titles_desc', true);
        $focus = (string) get_post_meta($postId, '_seopress_analysis_target_kw', true);
        $canon = (string) get_post_meta($postId, '_seopress_robots_canonical', true);
        $noindex = (string) get_post_meta($postId, '_seopress_robots_index', true);
        $nofollow = (string) get_post_meta($postId, '_seopress_robots_follow', true);

        $title = $this->cleanText($title);
        $desc  = $this->cleanText($desc);
        $focus = $this->cleanText($focus);
        // SEOPress can store multiple keywords separated by commas.
        if (strpos($focus, ',') !== false) {
            $parts = array_filter(array_map('trim', explode(',', $focus)), static function ($v): bool {
                return $v !== '';
            });
            $focus = !empty($parts) ? (string) reset($parts) : '';
        }
        $canon = $this->cleanUrl($canon);

        if ($title === '') {
            $counts['titles_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::TITLE, $title, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['titles'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['titles_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['titles_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        if ($desc === '') {
            $counts['descriptions_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::DESCRIPTION, $desc, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['descriptions'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['descriptions_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['descriptions_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        // Focus keyword (best-effort).
        if ($focus === '') {
            $counts['focus_keywords_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::FOCUS_KEYWORD, $focus, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['focus_keywords'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['focus_keywords_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['focus_keywords_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        if ($canon === '') {
            $counts['canonicals_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::CANONICAL, $canon, $overwrite, false);
            if (!empty($o['updated'])) {
                $counts['canonicals'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['canonicals_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['canonicals_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        $rIndex = $this->parseSeopressRobotsIndex($noindex);
        if ($rIndex === '') {
            $counts['robots_index_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::ROBOTS_INDEX, $rIndex, $overwrite, true);
            if (!empty($o['updated'])) {
                $counts['robots_index'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['robots_index_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['robots_index_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        $rFollow = $this->parseSeopressRobotsFollow($nofollow);
        if ($rFollow === '') {
            $counts['robots_follow_missing_source'] = 1;
        } else {
            $o = $this->postMetaOutcome($postId, MetaKeys::ROBOTS_FOLLOW, $rFollow, $overwrite, true);
            if (!empty($o['updated'])) {
                $counts['robots_follow'] = 1;
                if (!empty($o['overwritten'])) {
                    $counts['robots_follow_overwritten'] = 1;
                }
                $counts['updated'] = true;
            } elseif (!empty($o['skipped_existing'])) {
                $counts['robots_follow_skipped_existing'] = 1;
                $counts['skipped_existing']++;
            }
        }

        // Social (OpenGraph/Twitter)
        if ($includeSocial) {
            $ogTitle = (string) get_post_meta($postId, '_seopress_social_fb_title', true);
            $ogDesc  = (string) get_post_meta($postId, '_seopress_social_fb_desc', true);
            $ogImg   = (string) get_post_meta($postId, '_seopress_social_fb_img', true);
            $ogImgId = get_post_meta($postId, '_seopress_social_fb_img_attachment_id', true);

            $twTitle = (string) get_post_meta($postId, '_seopress_social_twitter_title', true);
            $twDesc  = (string) get_post_meta($postId, '_seopress_social_twitter_desc', true);
            $twImg   = (string) get_post_meta($postId, '_seopress_social_twitter_img', true);
            $twImgId = get_post_meta($postId, '_seopress_social_twitter_img_attachment_id', true);

            $ogTitle = $this->cleanText($ogTitle);
            $ogDesc  = $this->cleanText($ogDesc);
            $ogImg   = $this->resolveImageUrl($this->cleanUrl($ogImg), $ogImgId);

            $twTitle = $this->cleanText($twTitle);
            $twDesc  = $this->cleanText($twDesc);
            $twImg   = $this->resolveImageUrl($this->cleanUrl($twImg), $twImgId);

            if ($ogTitle === '') {
                $counts['og_titles_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_TITLE, $ogTitle, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_titles'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_titles_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_titles_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($ogDesc === '') {
                $counts['og_descriptions_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_DESCRIPTION, $ogDesc, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_descriptions'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_descriptions_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_descriptions_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($ogImg === '') {
                $counts['og_images_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::OG_IMAGE, $ogImg, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['og_images'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['og_images_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['og_images_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($twTitle === '') {
                $counts['twitter_titles_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_TITLE, $twTitle, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_titles'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_titles_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_titles_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($twDesc === '') {
                $counts['twitter_descriptions_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_DESCRIPTION, $twDesc, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_descriptions'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_descriptions_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_descriptions_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }

            if ($twImg === '') {
                $counts['twitter_images_missing_source'] = 1;
            } else {
                $o = $this->postMetaOutcome($postId, MetaKeys::TWITTER_IMAGE, $twImg, $overwrite, false);
                if (!empty($o['updated'])) {
                    $counts['twitter_images'] = 1;
                    if (!empty($o['overwritten'])) {
                        $counts['twitter_images_overwritten'] = 1;
                    }
                    $counts['updated'] = true;
                } elseif (!empty($o['skipped_existing'])) {
                    $counts['twitter_images_skipped_existing'] = 1;
                    $counts['skipped_existing']++;
                }
            }
        }

        // Schema type (not supported yet for SEOPress).
        if ($includeSchema) {
            $counts['schema_types_missing_source'] = 1;
        }

        return $counts;
    }

    /**
     * @param array<string, mixed> $meta
     * @return array<string, bool>
     */
    private function importYoastTerm(int $termId, string $taxonomy, array $meta, bool $overwrite, bool $includeSocial = true): array
    {
        $result = ['updated' => false];

        // Best-effort: support both wpseo_* keys and unprefixed keys.
        $title = '';
        $desc  = '';
        $canon = '';
        $noindex = '';
        $nofollow = '';

        $ogTitle = '';
        $ogDesc  = '';
        $ogImg   = '';
        $twTitle = '';
        $twDesc  = '';
        $twImg   = '';

        if (isset($meta['wpseo_title'])) {
            $title = (string) $meta['wpseo_title'];
        } elseif (isset($meta['title'])) {
            $title = (string) $meta['title'];
        }

        if (isset($meta['wpseo_desc'])) {
            $desc = (string) $meta['wpseo_desc'];
        } elseif (isset($meta['desc'])) {
            $desc = (string) $meta['desc'];
        }

        if (isset($meta['wpseo_canonical'])) {
            $canon = (string) $meta['wpseo_canonical'];
        } elseif (isset($meta['canonical'])) {
            $canon = (string) $meta['canonical'];
        }

        if (isset($meta['wpseo_noindex'])) {
            $noindex = (string) $meta['wpseo_noindex'];
        } elseif (isset($meta['noindex'])) {
            $noindex = (string) $meta['noindex'];
        }

        if (isset($meta['wpseo_nofollow'])) {
            $nofollow = (string) $meta['wpseo_nofollow'];
        } elseif (isset($meta['nofollow'])) {
            $nofollow = (string) $meta['nofollow'];
        }

        if ($includeSocial) {
            // Yoast taxonomy keys are stored in the wpseo_taxonomy_meta option.
            // Keys commonly include the wpseo_ prefix and may vary between hyphen/underscore forms.
            $ogTitle = $this->yoastGetFirstStringByKeys($meta, [
                'wpseo_opengraph-title',
                'wpseo_opengraph_title',
                'opengraph-title',
                'opengraph_title',
            ]);
            $ogDesc  = $this->yoastGetFirstStringByKeys($meta, [
                'wpseo_opengraph-description',
                'wpseo_opengraph_description',
                'opengraph-description',
                'opengraph_description',
            ]);
            $ogImg   = $this->yoastGetFirstImageUrlByKeys($meta, [
                'wpseo_opengraph-image',
                'wpseo_opengraph_image',
                'wpseo_opengraph-image-url',
                'wpseo_opengraph_image_url',
                'wpseo_opengraph-image-meta',
                'wpseo_opengraph_image_meta',
                'wpseo_opengraph-image_meta',
                'wpseo_opengraph_image-meta',
                'wpseo_opengraph-image-id',
                'wpseo_opengraph-image_id',
                'wpseo_opengraph_image_id',
                'opengraph-image',
                'opengraph_image',
                'opengraph-image-url',
                'opengraph_image_url',
                'opengraph-image-meta',
                'opengraph_image_meta',
                'opengraph-image_meta',
                'opengraph_image-meta',
                'opengraph-image-id',
                'opengraph-image_id',
                'opengraph_image_id',
            ]);

            $twTitle = $this->yoastGetFirstStringByKeys($meta, [
                'wpseo_twitter-title',
                'wpseo_twitter_title',
                'twitter-title',
                'twitter_title',
            ]);
            $twDesc  = $this->yoastGetFirstStringByKeys($meta, [
                'wpseo_twitter-description',
                'wpseo_twitter_description',
                'twitter-description',
                'twitter_description',
            ]);
            $twImg   = $this->yoastGetFirstImageUrlByKeys($meta, [
                'wpseo_twitter-image',
                'wpseo_twitter_image',
                'wpseo_twitter-image-url',
                'wpseo_twitter_image_url',
                'wpseo_twitter-image-meta',
                'wpseo_twitter_image_meta',
                'wpseo_twitter-image_meta',
                'wpseo_twitter_image-meta',
                'wpseo_twitter-image-id',
                'wpseo_twitter-image_id',
                'wpseo_twitter_image_id',
                'twitter-image',
                'twitter_image',
                'twitter-image-url',
                'twitter_image_url',
                'twitter-image-meta',
                'twitter_image_meta',
                'twitter-image_meta',
                'twitter_image-meta',
                'twitter-image-id',
                'twitter-image_id',
                'twitter_image_id',
            ]);
        }

        $title = $this->convertYoastVars($this->cleanText($title));
        $desc  = $this->convertYoastVars($this->cleanText($desc));
        $canon = $this->cleanUrl($canon);

        $ogTitle = $this->convertYoastVars($this->cleanText($ogTitle));
        $ogDesc  = $this->convertYoastVars($this->cleanText($ogDesc));
        $twTitle = $this->convertYoastVars($this->cleanText($twTitle));
        $twDesc  = $this->convertYoastVars($this->cleanText($twDesc));

        if ($title !== '') {
            if ($this->maybeUpdateTermMeta($termId, MetaKeys::TITLE, $title, $overwrite)) {
                $result['updated'] = true;
            }
        }

        if ($desc !== '') {
            if ($this->maybeUpdateTermMeta($termId, MetaKeys::DESCRIPTION, $desc, $overwrite)) {
                $result['updated'] = true;
            }
        }

        if ($canon !== '') {
            if ($this->maybeUpdateTermMeta($termId, MetaKeys::CANONICAL, $canon, $overwrite)) {
                $result['updated'] = true;
            }
        }

        $rIndex = $this->parseYoastRobotsIndex($noindex);
        if ($rIndex !== '') {
            if ($this->maybeUpdateTermRobotsMeta($termId, MetaKeys::ROBOTS_INDEX, $rIndex, $overwrite)) {
                $result['updated'] = true;
            }
        }

        $rFollow = $this->parseYoastRobotsFollow($nofollow);
        if ($rFollow !== '') {
            if ($this->maybeUpdateTermRobotsMeta($termId, MetaKeys::ROBOTS_FOLLOW, $rFollow, $overwrite)) {
                $result['updated'] = true;
            }
        }

        if ($includeSocial) {
            if ($ogTitle !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::OG_TITLE, $ogTitle, $overwrite)) {
                    $result['updated'] = true;
                }
            }
            if ($ogDesc !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::OG_DESCRIPTION, $ogDesc, $overwrite)) {
                    $result['updated'] = true;
                }
            }
            if ($ogImg !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::OG_IMAGE, $ogImg, $overwrite)) {
                    $result['updated'] = true;
                }
            }

            if ($twTitle !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::TWITTER_TITLE, $twTitle, $overwrite)) {
                    $result['updated'] = true;
                }
            }
            if ($twDesc !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::TWITTER_DESCRIPTION, $twDesc, $overwrite)) {
                    $result['updated'] = true;
                }
            }
            if ($twImg !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::TWITTER_IMAGE, $twImg, $overwrite)) {
                    $result['updated'] = true;
                }
            }
        }

        return $result;
    }

    /**
     * @return array<string, bool>
     */
    private function importSeopressTerm(int $termId, bool $overwrite, bool $includeSocial = true): array
    {
        $result = ['updated' => false];

        $title = (string) get_term_meta($termId, '_seopress_titles_title', true);
        $desc  = (string) get_term_meta($termId, '_seopress_titles_desc', true);
        $canon = (string) get_term_meta($termId, '_seopress_robots_canonical', true);
        $noindex = (string) get_term_meta($termId, '_seopress_robots_index', true);
        $nofollow = (string) get_term_meta($termId, '_seopress_robots_follow', true);

        $ogTitle = '';
        $ogDesc  = '';
        $ogImg   = '';
        $twTitle = '';
        $twDesc  = '';
        $twImg   = '';

        if ($includeSocial) {
            $ogTitle = (string) get_term_meta($termId, '_seopress_social_fb_title', true);
            $ogDesc  = (string) get_term_meta($termId, '_seopress_social_fb_desc', true);
            $ogImg   = (string) get_term_meta($termId, '_seopress_social_fb_img', true);

            $twTitle = (string) get_term_meta($termId, '_seopress_social_twitter_title', true);
            $twDesc  = (string) get_term_meta($termId, '_seopress_social_twitter_desc', true);
            $twImg   = (string) get_term_meta($termId, '_seopress_social_twitter_img', true);
        }

        $title = $this->cleanText($title);
        $desc  = $this->cleanText($desc);
        $canon = $this->cleanUrl($canon);

        $ogTitle = $this->cleanText($ogTitle);
        $ogDesc  = $this->cleanText($ogDesc);
        $ogImg   = $this->cleanUrl($ogImg);
        $twTitle = $this->cleanText($twTitle);
        $twDesc  = $this->cleanText($twDesc);
        $twImg   = $this->cleanUrl($twImg);

        if ($title !== '') {
            if ($this->maybeUpdateTermMeta($termId, MetaKeys::TITLE, $title, $overwrite)) {
                $result['updated'] = true;
            }
        }

        if ($desc !== '') {
            if ($this->maybeUpdateTermMeta($termId, MetaKeys::DESCRIPTION, $desc, $overwrite)) {
                $result['updated'] = true;
            }
        }

        if ($canon !== '') {
            if ($this->maybeUpdateTermMeta($termId, MetaKeys::CANONICAL, $canon, $overwrite)) {
                $result['updated'] = true;
            }
        }

        $rIndex = $this->parseSeopressRobotsIndex($noindex);
        if ($rIndex !== '') {
            if ($this->maybeUpdateTermRobotsMeta($termId, MetaKeys::ROBOTS_INDEX, $rIndex, $overwrite)) {
                $result['updated'] = true;
            }
        }

        $rFollow = $this->parseSeopressRobotsFollow($nofollow);
        if ($rFollow !== '') {
            if ($this->maybeUpdateTermRobotsMeta($termId, MetaKeys::ROBOTS_FOLLOW, $rFollow, $overwrite)) {
                $result['updated'] = true;
            }
        }

        if ($includeSocial) {
            if ($ogTitle !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::OG_TITLE, $ogTitle, $overwrite)) {
                    $result['updated'] = true;
                }
            }
            if ($ogDesc !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::OG_DESCRIPTION, $ogDesc, $overwrite)) {
                    $result['updated'] = true;
                }
            }
            if ($ogImg !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::OG_IMAGE, $ogImg, $overwrite)) {
                    $result['updated'] = true;
                }
            }

            if ($twTitle !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::TWITTER_TITLE, $twTitle, $overwrite)) {
                    $result['updated'] = true;
                }
            }
            if ($twDesc !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::TWITTER_DESCRIPTION, $twDesc, $overwrite)) {
                    $result['updated'] = true;
                }
            }
            if ($twImg !== '') {
                if ($this->maybeUpdateTermMeta($termId, MetaKeys::TWITTER_IMAGE, $twImg, $overwrite)) {
                    $result['updated'] = true;
                }
            }
        }

        return $result;
    }

    // ---------------- Helpers: DB / Jobs ----------------

    private function jobKey(string $jobId): string
    {
        return 'wpsn_import_job_' . $jobId;
    }

    /**
     * @return array<string, mixed>|null
     */
    private function getJob(string $jobId): ?array
    {
        $job = get_transient($this->jobKey($jobId));
        return is_array($job) ? $job : null;
    }

    /**
     * @param array<string, mixed> $job
     */
    private function setJob(string $jobId, array $job): void
    {
        set_transient($this->jobKey($jobId), $job, self::JOB_TTL);
    }

    private function deleteJob(string $jobId): void
    {
        delete_transient($this->jobKey($jobId));
    }

    private function redirectNotice(string $notice, array $extra = []): void
    {
        $args = array_merge(['wpsn_notice' => $notice], $extra);
        wp_safe_redirect($this->pageUrl($args));
        exit;
    }

    private function pageUrl(array $args = []): string
    {
        $base = admin_url('tools.php?page=' . self::PAGE_SLUG);
        if (empty($args)) {
            return $base;
        }
        return add_query_arg(array_map('rawurlencode', $args), $base);
    }

    private function jobProcessUrl(string $jobId): string
    {
        $url = add_query_arg([
            'action' => 'wpsn_process_migration',
            'job'    => $jobId,
        ], admin_url('admin-post.php'));

        $url = wp_nonce_url($url, 'wpsn_migration_' . $jobId);
        return $url;
    }

    private function jobCancelUrl(string $jobId): string
    {
        $url = add_query_arg([
            'action' => 'wpsn_cancel_migration',
            'job'    => $jobId,
        ], admin_url('admin-post.php'));

        $url = wp_nonce_url($url, 'wpsn_migration_' . $jobId);
        return $url;
    }

    /**
     * @param array<string,mixed> $job
     * @return array<string,mixed>
     */
    private function buildMigrationReport(array $job): array
    {
        $source = isset($job['source']) ? (string) $job['source'] : '';

        $counts = isset($job['counts']) && is_array($job['counts']) ? $job['counts'] : [];

        $fields = [
            'title' => [
                'imported' => (int) ($counts['titles'] ?? 0),
                'overwritten' => (int) ($counts['titles_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['titles_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['titles_missing_source'] ?? 0),
            ],
            'description' => [
                'imported' => (int) ($counts['descriptions'] ?? 0),
                'overwritten' => (int) ($counts['descriptions_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['descriptions_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['descriptions_missing_source'] ?? 0),
            ],
            'focus_keyword' => [
                'imported' => (int) ($counts['focus_keywords'] ?? 0),
                'overwritten' => (int) ($counts['focus_keywords_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['focus_keywords_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['focus_keywords_missing_source'] ?? 0),
            ],
            'canonical' => [
                'imported' => (int) ($counts['canonicals'] ?? 0),
                'overwritten' => (int) ($counts['canonicals_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['canonicals_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['canonicals_missing_source'] ?? 0),
            ],
            'robots_index' => [
                'imported' => (int) ($counts['robots_index'] ?? 0),
                'overwritten' => (int) ($counts['robots_index_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['robots_index_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['robots_index_missing_source'] ?? 0),
            ],
            'robots_follow' => [
                'imported' => (int) ($counts['robots_follow'] ?? 0),
                'overwritten' => (int) ($counts['robots_follow_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['robots_follow_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['robots_follow_missing_source'] ?? 0),
            ],
            'og_title' => [
                'imported' => (int) ($counts['og_titles'] ?? 0),
                'overwritten' => (int) ($counts['og_titles_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['og_titles_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['og_titles_missing_source'] ?? 0),
            ],
            'og_description' => [
                'imported' => (int) ($counts['og_descriptions'] ?? 0),
                'overwritten' => (int) ($counts['og_descriptions_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['og_descriptions_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['og_descriptions_missing_source'] ?? 0),
            ],
            'og_image' => [
                'imported' => (int) ($counts['og_images'] ?? 0),
                'overwritten' => (int) ($counts['og_images_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['og_images_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['og_images_missing_source'] ?? 0),
            ],
            'twitter_title' => [
                'imported' => (int) ($counts['twitter_titles'] ?? 0),
                'overwritten' => (int) ($counts['twitter_titles_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['twitter_titles_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['twitter_titles_missing_source'] ?? 0),
            ],
            'twitter_description' => [
                'imported' => (int) ($counts['twitter_descriptions'] ?? 0),
                'overwritten' => (int) ($counts['twitter_descriptions_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['twitter_descriptions_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['twitter_descriptions_missing_source'] ?? 0),
            ],
            'twitter_image' => [
                'imported' => (int) ($counts['twitter_images'] ?? 0),
                'overwritten' => (int) ($counts['twitter_images_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['twitter_images_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['twitter_images_missing_source'] ?? 0),
            ],
            'schema_type' => [
                'imported' => (int) ($counts['schema_types'] ?? 0),
                'overwritten' => (int) ($counts['schema_types_overwritten'] ?? 0),
                'skipped_existing' => (int) ($counts['schema_types_skipped_existing'] ?? 0),
                'missing_source' => (int) ($counts['schema_types_missing_source'] ?? 0),
            ],
        ];

        return [
            'job' => [
                'id' => (string) ($job['id'] ?? ''),
                'source' => $source,
                'post_types' => isset($job['post_types']) && is_array($job['post_types']) ? array_values(array_map('strval', $job['post_types'])) : [],
                'overwrite' => !empty($job['overwrite']),
                'include_terms' => !empty($job['include_terms']),
                'include_social' => !empty($job['include_social']),
                'include_schema' => !empty($job['include_schema']),
                'stage' => (string) ($job['stage'] ?? ''),
                'processed' => (int) ($job['processed'] ?? 0),
                'total' => (int) ($job['total'] ?? 0),
                'started_at' => (int) ($job['started_at'] ?? 0),
            ],
            'counts' => [
                'posts_updated' => (int) ($counts['posts_updated'] ?? 0),
                'skipped_existing_total' => (int) ($counts['skipped_existing'] ?? 0),
            ],
            'fields' => $fields,
            'terms' => isset($job['terms']) && is_array($job['terms']) ? $job['terms'] : [],
            'capabilities' => $this->sourceCapabilities($source, $job),
        ];
    }

    /**
     * What we migrate (today) vs what we don't.
     *
     * @return array{posts:string[],terms:string[],not_supported:string[]}
     */
    private function sourceCapabilities(string $source, array $job = []): array
    {
        $posts = ['title', 'description', 'canonical', 'robots_index', 'robots_follow'];

        // Best-effort: import focus keyword for supported sources.
        if (in_array($source, ['yoast', 'rankmath', 'seopress'], true)) {
            $posts[] = 'focus_keyword';
        }

        if (!empty($job['include_social'])) {
            $posts[] = 'og_title';
            $posts[] = 'og_description';
            $posts[] = 'og_image';
            $posts[] = 'twitter_title';
            $posts[] = 'twitter_description';
            $posts[] = 'twitter_image';
        }

        if (!empty($job['include_schema']) && $source !== 'seopress') {
            $posts[] = 'schema_type';
        }

        $terms = [];
        if (in_array($source, ['yoast', 'seopress'], true) && !empty($job['include_terms'])) {
            // Yoast uses wpseo_taxonomy_meta; SEOPress uses termmeta. Both are best-effort.
            $terms = ['title', 'description', 'canonical', 'robots_index', 'robots_follow'];

            if (!empty($job['include_social'])) {
                $terms[] = 'og_title';
                $terms[] = 'og_description';
                $terms[] = 'og_image';
                $terms[] = 'twitter_title';
                $terms[] = 'twitter_description';
                $terms[] = 'twitter_image';
            }
        }

        // This list is intentionally explicit for "what was NOT imported".
        $notSupported = [
            'redirects',
            'readability_scores',
            'breadcrumbs_settings',
        ];

        if ($source === 'seopress') {
            $notSupported[] = 'schema_type';
        }

        if (!in_array($source, ['yoast', 'rankmath', 'seopress'], true)) {
            $notSupported[] = 'focus_keywords';
        }

        return [
            'posts' => $posts,
            'terms' => $terms,
            'not_supported' => $notSupported,
        ];
    }

    /**
     * @return array<string, array{label:string, detected:bool}>
     */
    private function getSourceInfo(): array
    {
        global $wpdb;

        $yoastDetected = (bool) $wpdb->get_var($wpdb->prepare(
            "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key IN (%s, %s) LIMIT 1",
            '_yoast_wpseo_title',
            '_yoast_wpseo_metadesc'
        ));

        $rankDetected = (bool) $wpdb->get_var($wpdb->prepare(
            "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key IN (%s, %s) LIMIT 1",
            'rank_math_title',
            'rank_math_description'
        ));

        $seopressDetected = (bool) $wpdb->get_var($wpdb->prepare(
            "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key IN (%s, %s) LIMIT 1",
            '_seopress_titles_title',
            '_seopress_titles_desc'
        ));

        $aioseoDetected = false;

        $aioseoPostsTable = $wpdb->prefix . 'aioseo_posts';
        $tableExists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($aioseoPostsTable)));
        if (is_string($tableExists) && $tableExists === $aioseoPostsTable) {
            $aioseoDetected = true;
        } else {
            $aioseoDetected = (bool) $wpdb->get_var($wpdb->prepare(
                "SELECT 1 FROM {$wpdb->postmeta} WHERE meta_key IN (%s, %s, %s) LIMIT 1",
                '_aioseo_description',
                '_aioseop_title',
                '_aioseop_description'
            ));
        }

        return [
            'yoast' => [
                'label'    => __('Yoast SEO', 'wp-seo-next'),
                'detected' => $yoastDetected,
            ],
            'rankmath' => [
                'label'    => __('Rank Math', 'wp-seo-next'),
                'detected' => $rankDetected,
            ],
            'seopress' => [
                'label'    => __('SEOPress', 'wp-seo-next'),
                'detected' => $seopressDetected,
            ],
            'aioseo' => [
                'label'    => __('AIOSEO / All in One SEO', 'wp-seo-next'),
                'detected' => $aioseoDetected,
            ],
        ];
    }

    /**
     * @return string[]
     */
    private function getSelectablePostTypes(): array
    {
        $pts = get_post_types(['public' => true], 'names');
        $out = [];
        foreach ($pts as $pt) {
            if ($pt === 'attachment') {
                continue;
            }
            $out[] = $pt;
        }
        sort($out);
        return $out;
    }

    /**
     * @param string[] $postTypes
     */
    private function countPosts(array $postTypes): int
    {
        global $wpdb;

        $postTypes = array_values(array_filter(array_map('sanitize_key', $postTypes)));
        if (empty($postTypes)) {
            return 0;
        }

        $statuses = ['publish', 'future', 'draft', 'pending', 'private'];
        $ptPlaceholders = implode(',', array_fill(0, count($postTypes), '%s'));
        $stPlaceholders = implode(',', array_fill(0, count($statuses), '%s'));

        $sql = "SELECT COUNT(ID) FROM {$wpdb->posts} WHERE post_type IN ({$ptPlaceholders}) AND post_status IN ({$stPlaceholders})";
        $params = array_merge($postTypes, $statuses);

        $count = (int) $wpdb->get_var($wpdb->prepare($sql, $params));
        return max(0, $count);
    }

    /**
     * @param string[] $postTypes
     * @return int[]
     */
    private function getPostIdsBatch(array $postTypes, int $lastId, int $limit): array
    {
        global $wpdb;

        $postTypes = array_values(array_filter(array_map('sanitize_key', $postTypes)));
        if (empty($postTypes)) {
            return [];
        }

        $statuses = ['publish', 'future', 'draft', 'pending', 'private'];

        $ptPlaceholders = implode(',', array_fill(0, count($postTypes), '%s'));
        $stPlaceholders = implode(',', array_fill(0, count($statuses), '%s'));

        $sql = "SELECT ID FROM {$wpdb->posts} WHERE post_type IN ({$ptPlaceholders}) AND post_status IN ({$stPlaceholders}) AND ID > %d ORDER BY ID ASC LIMIT %d";
        $params = array_merge($postTypes, $statuses, [$lastId, $limit]);

        $ids = $wpdb->get_col($wpdb->prepare($sql, $params));
        if (!is_array($ids)) {
            return [];
        }

        $out = [];
        foreach ($ids as $id) {
            $id = (int) $id;
            if ($id > 0) {
                $out[] = $id;
            }
        }
        return $out;
    }

    /**
     * SEOPress stores taxonomy SEO in termmeta, so we iterate only terms that have SEOPress meta.
     *
     * @return int[]
     */
    private function getSeopressTermIdsBatch(int $lastId, int $limit): array
    {
        global $wpdb;

        $lastId = max(0, (int) $lastId);
        $limit = max(1, (int) $limit);

        $keys = [
            '_seopress_titles_title',
            '_seopress_titles_desc',
            '_seopress_robots_canonical',
            '_seopress_robots_index',
            '_seopress_robots_follow',
            '_seopress_social_fb_title',
            '_seopress_social_fb_desc',
            '_seopress_social_fb_img',
            '_seopress_social_twitter_title',
            '_seopress_social_twitter_desc',
            '_seopress_social_twitter_img',
        ];

        $placeholders = implode(',', array_fill(0, count($keys), '%s'));
        $sql = "SELECT DISTINCT term_id FROM {$wpdb->termmeta} WHERE meta_key IN ({$placeholders}) AND term_id > %d ORDER BY term_id ASC LIMIT %d";
        $params = array_merge($keys, [$lastId, $limit]);

        $ids = $wpdb->get_col($wpdb->prepare($sql, $params));
        if (!is_array($ids)) {
            return [];
        }

        $out = [];
        foreach ($ids as $id) {
            $id = (int) $id;
            if ($id > 0) {
                $out[] = $id;
            }
        }
        return $out;
    }

    // ---------------- Helpers: field parsing & updates ----------------

    private function cleanText(string $text): string
    {
        $text = wp_strip_all_tags($text);
        $text = preg_replace('/\s+/u', ' ', $text);
        $text = is_string($text) ? trim($text) : '';
        return $text;
    }

    private function cleanUrl(string $url): string
    {
        $url = trim($url);
        if ($url === '') {
            return '';
        }
        $url = esc_url_raw($url);
        return is_string($url) ? trim($url) : '';
    }

    /**
     * Resolve an image URL either from a URL string or (if empty) an attachment ID.
     *
     * @param mixed $id
     */
    private function resolveImageUrl(string $url, $id): string
    {
        $url = trim($url);
        if ($url !== '') {
            return $url;
        }

        $idInt = 0;
        if (is_scalar($id) && $id !== '') {
            $idInt = (int) $id;
        }

        if ($idInt > 0) {
            $u = wp_get_attachment_url($idInt);
            if (is_string($u) && $u !== '') {
                return $this->cleanUrl($u);
            }
        }

        return '';
    }

    private function normalizeSchemaToken(string $value): string
    {
        $value = strtolower(trim($value));
        $value = preg_replace('/\s+/', '', $value);
        $value = preg_replace('/[^a-z0-9_\-]/', '', (string) $value);
        return is_string($value) ? $value : '';
    }

    private function sanitizeOurSchemaType(string $value): string
    {
        $v = sanitize_key($value);
        $allowed = ['none', 'article', 'webpage', 'product'];
        return in_array($v, $allowed, true) ? $v : '';
    }

    private function mapSchemaTypeFromYoast(string $pageType, string $articleType, string $postType): string
    {
        $pt = $this->normalizeSchemaToken($pageType);
        $at = $this->normalizeSchemaToken($articleType);
        $postTypeNorm = $this->normalizeSchemaToken($postType);

        // Explicit page-level "none" disables output entirely.
        if (in_array($pt, ['none', 'off', 'disabled', 'false'], true)) {
            return 'none';
        }

        // Product should win over article-type hints when the object is clearly a product.
        if (strpos($pt, 'product') !== false || strpos($at, 'product') !== false || $postTypeNorm === 'product') {
            return 'product';
        }

        // Article type "none" in Yoast usually means "no article subtype", not "disable all schema".
        if ($at !== '' && !in_array($at, ['default', '0', 'none', 'off', 'disabled', 'false'], true)) {
            return 'article';
        }

        // Any explicit non-product page type maps to a generic WebPage in our simplified model.
        if ($pt !== '' && !in_array($pt, ['default', '0'], true)) {
            return 'webpage';
        }

        return '';
    }

    private function mapSchemaTypeFromRankMath(string $rawType, string $postType): string
    {
        $t = $this->normalizeSchemaToken($rawType);
        if ($t === '' || $t === 'default' || $t === '0') {
            return '';
        }

        if (in_array($t, ['none', 'off', 'disabled', 'false'], true)) {
            return 'none';
        }

        if (strpos($t, 'product') !== false || $this->normalizeSchemaToken($postType) === 'product') {
            return 'product';
        }

        if (strpos($t, 'article') !== false || strpos($t, 'blogposting') !== false || strpos($t, 'newsarticle') !== false) {
            return 'article';
        }

        if (strpos($t, 'webpage') !== false || strpos($t, 'itempage') !== false || strpos($t, 'web') === 0) {
            return 'webpage';
        }

        // Some Rank Math values are already our simplified type keys.
        $maybe = $this->sanitizeOurSchemaType($t);
        return $maybe;
    }

    private function mapSchemaTypeFromAioseo(string $rawType, string $postType): string
    {
        $t = $this->normalizeSchemaToken($rawType);
        if ($t === '' || $t === 'default' || $t === '0') {
            return '';
        }

        if (in_array($t, ['none', 'off', 'disabled', 'false'], true)) {
            return 'none';
        }

        if (strpos($t, 'product') !== false || $this->normalizeSchemaToken($postType) === 'product') {
            return 'product';
        }

        if (strpos($t, 'article') !== false || strpos($t, 'blogposting') !== false || strpos($t, 'newsarticle') !== false) {
            return 'article';
        }

        if (strpos($t, 'webpage') !== false || strpos($t, 'webpage') !== false || strpos($t, 'web') === 0) {
            return 'webpage';
        }

        $maybe = $this->sanitizeOurSchemaType($t);
        return $maybe;
    }

    private function convertYoastVars(string $value): string
    {
        if ($value === '') {
            return '';
        }

        // Best-effort conversion for a few common Yoast variables to our TemplateEngine syntax.
        $map = [
            '%%title%%'      => '%title%',
            '%%sitename%%'   => '%sitename%',
            '%%sep%%'        => '%sep%',
            '%%excerpt%%'    => '%excerpt%',
            '%%page%%'       => '%page%',
            '%%pagenum%%'    => '%pagenum%',
            '%%term_title%%' => '%title%',
        ];

        return strtr($value, $map);
    }

    private function parseYoastRobotsIndex(string $value): string
    {
        $v = trim($value);
        if ($v === '') {
            return '';
        }

        // Yoast: 1=noindex, 2=index. Some sources may store strings.
        if ($v === '1' || $v === 'noindex') {
            return 'noindex';
        }
        if ($v === '2' || $v === 'index') {
            return 'index';
        }

        return '';
    }

    private function parseYoastRobotsFollow(string $value): string
    {
        $v = trim($value);
        if ($v === '') {
            return '';
        }

        // Yoast: 1=nofollow, blank=follow.
        if ($v === '1' || $v === 'nofollow') {
            return 'nofollow';
        }
        if ($v === '2' || $v === 'follow') {
            return 'follow';
        }

        return '';
    }

    private function parseSeopressRobotsIndex(string $value): string
    {
        $v = strtolower(trim($value));
        if ($v === '') {
            return '';
        }

        // SEOPress stores robot overrides as "yes" when enabled.
        // _seopress_robots_index corresponds to "noindex".
        if (in_array($v, ['yes', '1', 'true', 'on'], true)) {
            return 'noindex';
        }

        // Rare edge cases / imported content.
        if ($v === 'noindex') {
            return 'noindex';
        }
        if ($v === 'index') {
            return 'index';
        }

        return '';
    }

    private function parseSeopressRobotsFollow(string $value): string
    {
        $v = strtolower(trim($value));
        if ($v === '') {
            return '';
        }

        // _seopress_robots_follow corresponds to "nofollow".
        if (in_array($v, ['yes', '1', 'true', 'on'], true)) {
            return 'nofollow';
        }

        if ($v === 'nofollow') {
            return 'nofollow';
        }
        if ($v === 'follow') {
            return 'follow';
        }

        return '';
    }

    /**
     * @param mixed $robots
     * @return array{0:string,1:string}
     */
    private function parseRankMathRobots($robots): array
    {
        $directives = [];

        if (is_array($robots)) {
            foreach ($robots as $r) {
                if (is_scalar($r)) {
                    $directives[] = strtolower(trim((string) $r));
                }
            }
        } elseif (is_string($robots)) {
            $raw = strtolower($robots);
            $parts = preg_split('/[\s,]+/', $raw);
            if (is_array($parts)) {
                foreach ($parts as $p) {
                    $p = trim((string) $p);
                    if ($p !== '') {
                        $directives[] = $p;
                    }
                }
            }
        }

        $directives = array_values(array_filter(array_unique($directives)));

        $index = '';
        $follow = '';

        if (in_array('noindex', $directives, true)) {
            $index = 'noindex';
        } elseif (in_array('index', $directives, true)) {
            $index = 'index';
        }

        if (in_array('nofollow', $directives, true)) {
            $follow = 'nofollow';
        } elseif (in_array('follow', $directives, true)) {
            $follow = 'follow';
        }

        return [$index, $follow];
    }

    /**
     * @param mixed $robotsDefault
     * @param mixed $robotsNoindex
     * @param mixed $robotsNofollow
     * @return array{0:string,1:string}
     */
    private function parseAioseoRobots($robotsDefault, $robotsNoindex, $robotsNofollow): array
    {
        $index = '';
        $follow = '';

        // If using default settings, we do not import robots overrides.
        if ($robotsDefault !== null) {
            $default = (int) (is_scalar($robotsDefault) ? $robotsDefault : 0);
            if ($default === 1) {
                return ['', ''];
            }
        }

        // AIOSEO table stores booleans/ints; old AIOSEO pack uses 'on'.
        if ($robotsNoindex !== null && $robotsNoindex !== '') {
            $v = is_scalar($robotsNoindex) ? strtolower(trim((string) $robotsNoindex)) : '';
            if ($v === '1' || $v === 'true' || $v === 'on' || $v === 'yes') {
                $index = 'noindex';
            } elseif ($v === '0' || $v === 'false' || $v === 'off' || $v === 'no') {
                $index = 'index';
            }
        }

        if ($robotsNofollow !== null && $robotsNofollow !== '') {
            $v = is_scalar($robotsNofollow) ? strtolower(trim((string) $robotsNofollow)) : '';
            if ($v === '1' || $v === 'true' || $v === 'on' || $v === 'yes') {
                $follow = 'nofollow';
            } elseif ($v === '0' || $v === 'false' || $v === 'off' || $v === 'no') {
                $follow = 'follow';
            }
        }

        return [$index, $follow];
    }

    /**
     * Update a post meta key and return a richer outcome for reporting.
     *
     * @return array{updated:bool,skipped_existing:bool,skipped_empty:bool,overwritten:bool}
     */
    private function postMetaOutcome(int $postId, string $key, string $value, bool $overwrite, bool $treatDefaultAsEmpty): array
    {
        $value = trim($value);
        if ($value === '') {
            return [
                'updated' => false,
                'skipped_existing' => false,
                'skipped_empty' => true,
                'overwritten' => false,
            ];
        }

        $existing = (string) get_post_meta($postId, $key, true);
        $existing = trim($existing);

        if ($treatDefaultAsEmpty && $existing === 'default') {
            $existing = '';
        }

        if (!$overwrite && $existing !== '') {
            return [
                'updated' => false,
                'skipped_existing' => true,
                'skipped_empty' => false,
                'overwritten' => false,
            ];
        }

        update_post_meta($postId, $key, $value);

        return [
            'updated' => true,
            'skipped_existing' => false,
            'skipped_empty' => false,
            'overwritten' => ($existing !== ''),
        ];
    }

    private function maybeUpdatePostMeta(int $postId, string $key, string $value, bool $overwrite): bool
    {
        $value = trim($value);
        if ($value === '') {
            return false;
        }

        $existing = (string) get_post_meta($postId, $key, true);
        $existing = trim($existing);

        if (!$overwrite && $existing !== '') {
            return false;
        }

        update_post_meta($postId, $key, $value);
        return true;
    }

    private function maybeUpdateRobotsMeta(int $postId, string $key, string $value, bool $overwrite): bool
    {
        $value = trim($value);
        if ($value === '') {
            return false;
        }

        $existing = (string) get_post_meta($postId, $key, true);
        $existing = trim($existing);

        // Treat 'default' as empty (doesn't override).
        if ($existing === 'default') {
            $existing = '';
        }

        if (!$overwrite && $existing !== '') {
            return false;
        }

        update_post_meta($postId, $key, $value);
        return true;
    }

    private function maybeUpdateTermMeta(int $termId, string $key, string $value, bool $overwrite): bool
    {
        $value = trim($value);
        if ($value === '') {
            return false;
        }

        $existing = (string) get_term_meta($termId, $key, true);
        $existing = trim($existing);

        if (!$overwrite && $existing !== '') {
            return false;
        }

        update_term_meta($termId, $key, $value);
        return true;
    }

    private function maybeUpdateTermRobotsMeta(int $termId, string $key, string $value, bool $overwrite): bool
    {
        $value = trim($value);
        if ($value === '') {
            return false;
        }

        $existing = (string) get_term_meta($termId, $key, true);
        $existing = trim($existing);

        if ($existing === 'default') {
            $existing = '';
        }

        if (!$overwrite && $existing !== '') {
            return false;
        }

        update_term_meta($termId, $key, $value);
        return true;
    }
}
