<?php

namespace WPSN\Modules\Redirects\Admin;

use WPSN\Core\Logger;
use WPSN\Core\UndoRuns;
use WPSN\Modules\Redirects\RedirectMigrationUndoService;
use WPSN\Modules\Redirects\RedirectMigrationRegex;
use WPSN\Modules\Redirects\RedirectRepository;
use WPSN\Modules\Redirects\RedirectMigrationReportRepository;
use WPSN\Modules\Redirects\RedirectMigrationRunRepository;
use WPSN\Modules\Redirects\RedirectsSettingsRepository;
use WPSN\Modules\Revisions\RevisionsInstaller;
use WPSN\Modules\Revisions\RevisionsRepository;
use WPSN\Core\Capabilities;
use WPSN\Modules\ActivityLog\ActivityLogRepository;

defined('ABSPATH') || exit;

/**
 * Redirect migration wizard.
 *
 * Imports redirects from popular plugins into WPSN redirects table.
 *
 * Currently supported sources:
 *  - Redirection plugin (table: wp_redirection_items)
 *  - Rank Math (table: wp_rank_math_redirections)
 *  - AIOSEO (table: wp_aioseo_redirects)
 *  - Yoast SEO Premium (option: wpseo-premium-redirects-base)
 *  - SEOPress (post_type: seopress_404 + meta _seopress_redirections_*)
 */
final class RedirectsMigrationAdmin
{
    private const PAGE_SLUG = 'wpsn-redirect-migration';
    private const TRANSIENT_PREFIX = 'wpsn_redirects_migration_job_';

    private RedirectRepository $redirects;
    private Logger $logger;

    private RedirectMigrationReportRepository $report;
    private RedirectMigrationRunRepository $runs;

    public function __construct(RedirectRepository $redirects, Logger $logger)
    {
        $this->redirects = $redirects;
        $this->logger = $logger;

        $this->report = new RedirectMigrationReportRepository();
        $this->runs = new RedirectMigrationRunRepository();
    }

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

        add_action('admin_post_wpsn_redirects_migrate_start', [$this, 'handleStart']);
        add_action('admin_post_wpsn_redirects_migrate_step', [$this, 'handleStep']);
        add_action('admin_post_wpsn_redirects_migrate_cancel', [$this, 'handleCancel']);
        add_action('admin_post_wpsn_redirects_migrate_undo', [$this, 'handleUndo']);
        add_action('admin_post_wpsn_redirects_migrate_export_csv', [$this, 'handleExportCsv']);
        add_action('admin_post_wpsn_redirects_migrate_clear_report', [$this, 'handleClearReport']);
    }

    public function adminMenu(): void
    {
        add_management_page(
            __('SEO Redirect Migration', 'wp-seo-next'),
            __('SEO Redirect Migration', 'wp-seo-next'),
            Capabilities::CAP_REDIRECTS,
            self::PAGE_SLUG,
            [$this, 'renderPage']
        );
    }

    private function pageUrl(array $args = []): string
    {
        $base = admin_url('tools.php?page=' . self::PAGE_SLUG);
        if (!empty($args)) {
            $base = add_query_arg($args, $base);
        }
        return $base;
    }

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

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

        // If the transient expired, try to load the last saved run summary from DB.
        if ($job === null && $jobId !== '') {
            $run = $this->runs->get($jobId);
            if (is_array($run)) {
                $job = $this->normalizeRunToJob($run);
            }
        }

        echo '<div class="wrap">';
        echo '<h1>' . esc_html__('SEO Redirect Migration', 'wp-seo-next') . '</h1>';
        echo '<p class="description">' . esc_html__('Import redirects from other plugins into WP SEO Next Redirects table.', 'wp-seo-next') . '</p>';

        $revisionsUrl = admin_url('tools.php?page=wpsn-revisions&source=redirects_migration');
        echo '<p class="description">' . wp_kses_post(
            sprintf(
                /* translators: %s: URL */
                __('Undo / rollback: each inserted/updated redirect is saved into <a href="%s">Revisions</a> (source: redirects_migration).', 'wp-seo-next'),
                esc_url($revisionsUrl)
            )
        ) . '</p>';

        if (isset($_GET['notice']) && $_GET['notice'] === 'cancelled') {
            echo '<div class="notice notice-info"><p>' . esc_html__('Migration cancelled.', 'wp-seo-next') . '</p></div>';
        }
        if (isset($_GET['notice']) && $_GET['notice'] === 'finished') {
            echo '<div class="notice notice-success"><p>' . esc_html__('Migration finished.', 'wp-seo-next') . '</p></div>';
        }
        if (isset($_GET['notice']) && $_GET['notice'] === 'report_cleared') {
            echo '<div class="notice notice-info"><p>' . esc_html__('Migration report cleared.', 'wp-seo-next') . '</p></div>';
        }
        if (isset($_GET['notice']) && $_GET['notice'] === 'undo_done') {
            $deleted = isset($_GET['deleted']) ? (int) $_GET['deleted'] : 0;
            $restored = isset($_GET['restored']) ? (int) $_GET['restored'] : 0;
            $skipped = isset($_GET['skipped']) ? (int) $_GET['skipped'] : 0;
            $already = isset($_GET['already']) ? (int) $_GET['already'] : 0;

            $msg = sprintf(
                /* translators: %1$d deleted, %2$d restored, %3$d skipped, %4$d already */
                __('Undo complete. Deleted: %1$d · Restored: %2$d · Skipped: %3$d · Already rolled back: %4$d', 'wp-seo-next'),
                $deleted,
                $restored,
                $skipped,
                $already
            );
            echo '<div class="notice notice-success"><p>' . esc_html($msg) . '</p></div>';
        }
        if (isset($_GET['notice']) && $_GET['notice'] === 'error') {
            $msg = isset($_GET['msg']) ? sanitize_text_field((string) wp_unslash($_GET['msg'])) : '';
            echo '<div class="notice notice-error"><p>' . esc_html__('Migration error.', 'wp-seo-next');
            if ($msg !== '') {
                echo ' ' . esc_html($msg);
            }
            echo '</p></div>';
        }

        if (is_array($job)) {
            $this->renderJobBox($job);
        } else {
            $this->renderStartForm();
        }

        echo '</div>';
    }

    private function renderStartForm(): void
    {
        $sources = $this->availableSources();

        echo '<h2>' . esc_html__('Start migration', 'wp-seo-next') . '</h2>';
        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
        wp_nonce_field('wpsn_redirects_migrate_start', 'wpsn_nonce');
        echo '<input type="hidden" name="action" value="wpsn_redirects_migrate_start">';

        echo '<table class="form-table" role="presentation">';
        echo '<tr><th scope="row">' . esc_html__('Source plugin', 'wp-seo-next') . '</th><td>';

        echo '<select name="source">';
        foreach ($sources as $key => $src) {
            $disabled = empty($src['available']);
            printf(
                '<option value="%s" %s>%s</option>',
                esc_attr($key),
                $disabled ? 'disabled' : '',
                esc_html($src['label'] . ($disabled ? ' — ' . __('not detected', 'wp-seo-next') : ''))
            );
        }
        echo '</select>';
        if ($this->regexImportsEnabled()) {
            echo '<p class="description">' . esc_html__('We import supported exact, prefix, and regex rules. Query-string/full-URL/unsafe regex rules are still skipped.', 'wp-seo-next') . '</p>';
        } else {
            echo '<p class="description">' . esc_html__('We import supported exact & prefix rules. Regex rules stay skipped until Advanced regex redirects are enabled in the Redirects module.', 'wp-seo-next') . '</p>';
        }
        echo '</td></tr>';

        echo '<tr><th scope="row">' . esc_html__('Behavior', 'wp-seo-next') . '</th><td>';
        echo '<label style="display:block; margin: 4px 0;">';
        echo '<input type="checkbox" name="overwrite" value="1"> ' . esc_html__('Overwrite existing rules with the same source path and match type', 'wp-seo-next');
        echo '</label>';
        echo '<label style="display:block; margin: 4px 0;">';
        echo '<input type="checkbox" name="include_disabled" value="1"> ' . esc_html__('Also import disabled rules (as disabled)', 'wp-seo-next');
        echo '</label>';
        echo '</td></tr>';

        echo '</table>';

        submit_button(__('Start migration', 'wp-seo-next'));
        echo '</form>';

        echo '<p class="description">' . esc_html__('Tip: create a database backup before importing large redirect sets.', 'wp-seo-next') . '</p>';
    }

    /**
     * @param array<string, mixed> $job
     */
    private function renderJobBox(array $job): void
    {
        $jobId = (string) ($job['id'] ?? '');
        $source = (string) ($job['source'] ?? '');
        $done = !empty($job['done']);
        $offset = (int) ($job['offset'] ?? 0);
        $total = (int) ($job['total'] ?? 0);
        $processed = (int) ($job['processed'] ?? 0);

        $imported = (int) ($job['imported'] ?? 0);
        $updated  = (int) ($job['updated'] ?? 0);
        $skipped  = (int) ($job['skipped'] ?? 0);
        $unsupported = (int) ($job['unsupported'] ?? 0);
        $disabled = (int) ($job['disabled'] ?? 0);
        $errors = (int) ($job['errors'] ?? 0);

        $pct = $total > 0 ? (int) floor(($processed / $total) * 100) : 0;
        if ($pct > 100) {
            $pct = 100;
        }

        echo '<h2>' . esc_html__('Migration progress', 'wp-seo-next') . '</h2>';

        echo '<div class="notice notice-info" style="padding: 12px;">';
        echo '<p><strong>' . esc_html__('Source:', 'wp-seo-next') . '</strong> ' . esc_html($source) . '</p>';
        echo '<p><strong>' . esc_html__('Progress:', 'wp-seo-next') . '</strong> ' . esc_html((string) $pct) . '% (' . esc_html((string) $processed) . '/' . esc_html((string) $total) . ')</p>';
        echo '<p style="margin:6px 0 0;">';
        echo esc_html__('Imported:', 'wp-seo-next') . ' <strong>' . esc_html((string) $imported) . '</strong> · ';
        echo esc_html__('Updated:', 'wp-seo-next') . ' <strong>' . esc_html((string) $updated) . '</strong> · ';
        echo esc_html__('Skipped existing:', 'wp-seo-next') . ' <strong>' . esc_html((string) $skipped) . '</strong> · ';
        echo esc_html__('Unsupported:', 'wp-seo-next') . ' <strong>' . esc_html((string) $unsupported) . '</strong> · ';
        echo esc_html__('Disabled skipped:', 'wp-seo-next') . ' <strong>' . esc_html((string) $disabled) . '</strong> · ';
        echo esc_html__('Errors:', 'wp-seo-next') . ' <strong>' . esc_html((string) $errors) . '</strong>';
        echo '</p>';

        if (!empty($job['last_error'])) {
            echo '<p style="margin:8px 0 0; color:#b32d2e;">' . esc_html__('Last error:', 'wp-seo-next') . ' ' . esc_html((string) $job['last_error']) . '</p>';
        }

        echo '</div>';

        if ($done) {
            $reasonFilter = isset($_GET['reason']) ? sanitize_key((string) wp_unslash($_GET['reason'])) : '';

            $exportArgs = [
                'action' => 'wpsn_redirects_migrate_export_csv',
                'job' => $jobId,
            ];
            if ($reasonFilter !== '') {
                $exportArgs['reason'] = $reasonFilter;
            }
            $exportUrl = wp_nonce_url(
                add_query_arg($exportArgs, admin_url('admin-post.php')),
                'wpsn_redirects_migrate_export_csv',
                'wpsn_nonce'
            );

            echo '<p style="margin:12px 0;">';
            echo '<a class="button" href="' . esc_url($this->pageUrl()) . '">' . esc_html__('Start new migration', 'wp-seo-next') . '</a> ';
            echo '<a class="button button-primary" href="' . esc_url($exportUrl) . '">' . esc_html__('Download report CSV', 'wp-seo-next') . '</a>';
            echo '</p>';

            // One-click undo for this migration (safe by default).
            echo '<div style="margin:10px 0 14px; padding:12px; border:1px solid #ccd0d4; background:#fff; max-width: 980px;">';
            echo '<p style="margin:0 0 8px;"><strong>' . esc_html__('Undo this migration', 'wp-seo-next') . '</strong></p>';
            echo '<p class="description" style="margin:0 0 10px;">' . esc_html__('This will roll back redirects created/updated by this migration run. Safe mode skips redirects that changed after migration (hits counter is ignored).', 'wp-seo-next') . '</p>';
            $undoRun = UndoRuns::findLatest('redirects_migration', 'job_id', (string) $jobId);
            $undoRunId = is_array($undoRun) ? (string) ($undoRun['id'] ?? '') : '';

            if ($undoRunId !== '') {
                // Unified undo runs handler.
                echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
                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(['job' => $jobId])) . '">';
                wp_nonce_field('wpsn_undo_run_' . $undoRunId, 'wpsn_nonce');
            } else {
                // Fallback (legacy handler).
                echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '">';
                echo '<input type="hidden" name="action" value="wpsn_redirects_migrate_undo">';
                echo '<input type="hidden" name="job" value="' . esc_attr($jobId) . '">';
                wp_nonce_field('wpsn_redirects_migrate_undo', 'wpsn_nonce');
            }
            echo '<label style="display:block; margin:0 0 10px;">';
            echo '<input type="checkbox" name="force" value="1" /> ' . esc_html__('Force undo (also revert redirects that were edited after migration).', 'wp-seo-next');
            echo '</label>';
            echo '<button type="submit" class="button button-secondary" style="border-color:#b32d2e;color:#b32d2e;" onclick="return confirm(' . wp_json_encode(__('Undo this migration? This will change/delete redirects.', 'wp-seo-next')) . ');">' . esc_html__('Undo migration', 'wp-seo-next') . '</button>';
            echo '</form>';
            echo '</div>';

            echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="margin: 0 0 16px;">';
            wp_nonce_field('wpsn_redirects_migrate_clear_report', 'wpsn_nonce');
            echo '<input type="hidden" name="action" value="wpsn_redirects_migrate_clear_report">';
            echo '<input type="hidden" name="job" value="' . esc_attr($jobId) . '">';
            submit_button(__('Clear report data', 'wp-seo-next'), 'secondary', 'submit', false);
            echo '</form>';

            $this->renderReportBox($jobId);
            return;
        }

        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block; margin-right: 8px;">';
        wp_nonce_field('wpsn_redirects_migrate_step', 'wpsn_nonce');
        echo '<input type="hidden" name="action" value="wpsn_redirects_migrate_step">';
        echo '<input type="hidden" name="job" value="' . esc_attr($jobId) . '">';
        submit_button(__('Process next batch', 'wp-seo-next'), 'primary', 'submit', false);
        echo '</form>';

        echo '<form method="post" action="' . esc_url(admin_url('admin-post.php')) . '" style="display:inline-block;">';
        wp_nonce_field('wpsn_redirects_migrate_cancel', 'wpsn_nonce');
        echo '<input type="hidden" name="action" value="wpsn_redirects_migrate_cancel">';
        echo '<input type="hidden" name="job" value="' . esc_attr($jobId) . '">';
        submit_button(__('Cancel', 'wp-seo-next'), 'secondary', 'submit', false);
        echo '</form>';

        // Auto-continue via JS if requested.
        if (isset($_GET['auto']) && $_GET['auto'] === '1') {
            $stepUrl = wp_nonce_url(
                admin_url('admin-post.php?action=wpsn_redirects_migrate_step&job=' . rawurlencode($jobId)),
                'wpsn_redirects_migrate_step',
                'wpsn_nonce'
            );
            echo '<script>setTimeout(function(){ window.location.href = ' . wp_json_encode($stepUrl) . '; }, 250);</script>';
        } else {
            $autoUrl = $this->pageUrl(['job' => $jobId, 'auto' => 1]);
            echo '<p class="description" style="margin-top:10px;">' . sprintf(
                /* translators: %s is a link to enable auto-run */
                esc_html__('Want it to run automatically? %s', 'wp-seo-next'),
                '<a href="' . esc_url($autoUrl) . '">' . esc_html__('Enable auto-run', 'wp-seo-next') . '</a>'
            ) . '</p>';
        }
    }

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

        check_admin_referer('wpsn_redirects_migrate_start', 'wpsn_nonce');

        $source = isset($_POST['source']) ? sanitize_key((string) wp_unslash($_POST['source'])) : '';
        $overwrite = !empty($_POST['overwrite']);
        $includeDisabled = !empty($_POST['include_disabled']);

        if (!in_array($source, ['redirection', 'rankmath', 'aioseo', 'yoast', 'seopress'], true)) {
            wp_safe_redirect($this->pageUrl(['notice' => 'error', 'msg' => 'Invalid source']));
            exit;
        }
        if (!$this->sourceIsAvailable($source)) {
            wp_safe_redirect($this->pageUrl(['notice' => 'error', 'msg' => 'Source not detected']));
            exit;
        }

        $total = $this->countSourceRows($source);

        $jobId = strtolower(wp_generate_uuid4());
        $job = [
            'id' => $jobId,
            'source' => $source,
            'overwrite' => $overwrite,
            'include_disabled' => $includeDisabled,
            'offset' => 0,
            'processed' => 0,
            'total' => $total,
            'imported' => 0,
            'updated' => 0,
            'skipped' => 0,
            'unsupported' => 0,
            'disabled' => 0,
            'errors' => 0,
            'done' => false,
            'last_error' => '',
            'started_at' => time(),
        ];

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

        // Activity Log (best-effort).
        try {
            (new ActivityLogRepository())->insert(
                'redirects_migration_start',
                'Redirect migration started',
                [
                    'context' => 'redirects',
                    'object_type' => 'migration',
                    'object_key' => $jobId,
                    'meta' => [
                        'job_id' => $jobId,
                        'source' => $source,
                        'overwrite' => $overwrite ? 1 : 0,
                        'include_disabled' => $includeDisabled ? 1 : 0,
                        'total' => $total,
                    ],
                ]
            );
        } catch (\Throwable $e) {
            // silent
        }

        // Persist run summary so the report survives transient expiry.
        $this->runs->insert([
            'job_id' => $jobId,
            'source' => $source,
            'overwrite' => $overwrite,
            'include_disabled' => $includeDisabled,
            'total' => $total,
            'processed' => 0,
            'imported' => 0,
            'updated' => 0,
            'skipped' => 0,
            'unsupported' => 0,
            'disabled' => 0,
            'errors' => 0,
            'last_error' => '',
            'started_at' => current_time('mysql'),
            'finished_at' => null,
        ]);

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

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

        $jobId = isset($_POST['job']) ? sanitize_text_field((string) wp_unslash($_POST['job'])) : '';
        if ($jobId !== '') {
            $this->deleteJob($jobId);
            $this->report->deleteByJob($jobId);
            $this->runs->delete($jobId);

            // Activity Log (best-effort).
            try {
                (new ActivityLogRepository())->insert(
                    'redirects_migration_cancel',
                    'Redirect migration cancelled',
                    [
                        'context' => 'redirects',
                        'object_type' => 'migration',
                        'object_key' => $jobId,
                    ]
                );
            } catch (\Throwable $e) {
                // silent
            }
        }

        wp_safe_redirect($this->pageUrl(['notice' => 'cancelled']));
        exit;
    }

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

        $jobId = isset($_REQUEST['job']) ? sanitize_text_field((string) wp_unslash($_REQUEST['job'])) : '';
        if ($jobId === '') {
            wp_safe_redirect($this->pageUrl(['notice' => 'error', 'msg' => 'Missing job']));
            exit;
        }

        $job = $this->getJob($jobId);
        if (!is_array($job) || empty($job['id'])) {
            wp_safe_redirect($this->pageUrl(['notice' => 'error', 'msg' => 'Job not found']));
            exit;
        }

        if (!empty($job['done'])) {
            wp_safe_redirect($this->pageUrl(['job' => $jobId, 'notice' => 'finished']));
            exit;
        }

        $batch = 50;
        $source = (string) ($job['source'] ?? '');
        $offset = (int) ($job['offset'] ?? 0);
        $overwrite = !empty($job['overwrite']);
        $includeDisabled = !empty($job['include_disabled']);

        try {
            $rows = $this->fetchSourceBatch($source, $offset, $batch);
        } catch (\Throwable $e) {
            $job['errors'] = ((int) ($job['errors'] ?? 0)) + 1;
            $job['last_error'] = $e->getMessage();
            $this->setJob($jobId, $job);
            $this->logger->error('Redirect migration fetch error: ' . $e->getMessage());
            $this->runs->update($jobId, [
                'processed' => (int) ($job['processed'] ?? 0),
                'total' => (int) ($job['total'] ?? 0),
                'imported' => (int) ($job['imported'] ?? 0),
                'updated' => (int) ($job['updated'] ?? 0),
                'skipped' => (int) ($job['skipped'] ?? 0),
                'unsupported' => (int) ($job['unsupported'] ?? 0),
                'disabled' => (int) ($job['disabled'] ?? 0),
                'errors' => (int) ($job['errors'] ?? 0),
                'last_error' => (string) ($job['last_error'] ?? ''),
            ]);
            wp_safe_redirect($this->pageUrl(['job' => $jobId, 'notice' => 'error', 'msg' => 'Fetch failed']));
            exit;
        }

        if (empty($rows)) {
            $job['done'] = true;
            $this->setJob($jobId, $job);
            $this->runs->update($jobId, [
                'processed' => (int) ($job['processed'] ?? 0),
                'total' => (int) ($job['total'] ?? 0),
                'imported' => (int) ($job['imported'] ?? 0),
                'updated' => (int) ($job['updated'] ?? 0),
                'skipped' => (int) ($job['skipped'] ?? 0),
                'unsupported' => (int) ($job['unsupported'] ?? 0),
                'disabled' => (int) ($job['disabled'] ?? 0),
                'errors' => (int) ($job['errors'] ?? 0),
                'last_error' => (string) ($job['last_error'] ?? ''),
                'finished_at' => current_time('mysql'),
            ]);

            // Activity Log (best-effort).
            $this->logMigrationFinishedOnce($jobId, $job);
            wp_safe_redirect($this->pageUrl(['job' => $jobId, 'notice' => 'finished']));
            exit;
        }

        foreach ($rows as $row) {
            $job['processed'] = ((int) ($job['processed'] ?? 0)) + 1;
            $job['offset'] = ((int) ($job['offset'] ?? 0)) + 1;

            try {
                $rules = $this->mapSourceRowToRules($source, $row, $includeDisabled);
                if (empty($rules)) {
                    continue;
                }

                foreach ($rules as $rule) {
                    if (isset($rule['__unsupported'])) {
                        $job['unsupported'] = ((int) ($job['unsupported'] ?? 0)) + 1;
                        $r = isset($rule['__reason']) ? sanitize_key((string) $rule['__reason']) : '';
                        $reason = $r !== '' ? ('unsupported_' . $r) : 'unsupported';
                        $extra = ['stage' => 'map'];
                        if (!empty($rule['__message'])) {
                            $extra['message'] = sanitize_text_field((string) $rule['__message']);
                        }
                        if (!empty($rule['__warnings']) && is_array($rule['__warnings'])) {
                            $extra['warnings'] = array_values(array_map('sanitize_text_field', array_map('strval', $rule['__warnings'])));
                        }
                        $this->report->insert($jobId, $source, $reason, $rule, $extra);
                        continue;
                    }
                    if (isset($rule['__disabled'])) {
                        $job['disabled'] = ((int) ($job['disabled'] ?? 0)) + 1;
                        $this->report->insert($jobId, $source, 'disabled_skipped', $rule, ['stage' => 'map']);
                        continue;
                    }

                    // Capture "before" for revisions/undo.
                    $beforeRow = null;
                    if (!empty($rule['from_url']) && !empty($rule['match_type'])) {
                        $beforeRow = $this->redirects->findByFromAndType((string) $rule['from_url'], (string) $rule['match_type']);
                    }

                    $res = $this->redirects->upsertByFromAndType($rule, $overwrite);
                    if (($res['action'] ?? '') === 'inserted') {
                        $job['imported'] = ((int) ($job['imported'] ?? 0)) + 1;

                        $afterRow = $this->redirects->getById((int) ($res['id'] ?? 0));
                        $this->maybeLogRedirectRevision('create', (int) ($res['id'] ?? 0), $beforeRow, $afterRow, [
                            'migration_job_id' => $jobId,
                            'migration_source' => $source,
                            'overwrite' => $overwrite ? 1 : 0,
                        ]);
                    } elseif (($res['action'] ?? '') === 'updated') {
                        $job['updated'] = ((int) ($job['updated'] ?? 0)) + 1;

                        $afterRow = $this->redirects->getById((int) ($res['id'] ?? 0));
                        $this->maybeLogRedirectRevision('update', (int) ($res['id'] ?? 0), $beforeRow, $afterRow, [
                            'migration_job_id' => $jobId,
                            'migration_source' => $source,
                            'overwrite' => $overwrite ? 1 : 0,
                        ]);
                    } elseif (($res['action'] ?? '') === 'skipped') {
                        $job['skipped'] = ((int) ($job['skipped'] ?? 0)) + 1;
                        $this->report->insert($jobId, $source, 'skipped_existing', $rule, [
                            'existing_id' => (int) ($res['id'] ?? 0),
                            'overwrite' => $overwrite ? 1 : 0,
                        ]);
                    } else {
                        $job['errors'] = ((int) ($job['errors'] ?? 0)) + 1;
                        global $wpdb;
                        $this->report->insert($jobId, $source, 'db_error', $rule, [
                            'action' => (string) ($res['action'] ?? ''),
                            'wpdb_error' => is_object($wpdb) ? (string) $wpdb->last_error : '',
                        ]);
                    }
                }
            } catch (\Throwable $e) {
                $job['errors'] = ((int) ($job['errors'] ?? 0)) + 1;
                $job['last_error'] = $e->getMessage();
                $this->logger->error('Redirect migration row error: ' . $e->getMessage());

                // Best-effort: record exception into report for debugging.
                $this->report->insert($jobId, $source, 'exception', [
                    'from_url' => '',
                    'to_url' => '',
                    'match_type' => '',
                    'status_code' => 0,
                    'enabled' => 0,
                ], [
                    'message' => $e->getMessage(),
                    'row' => is_array($row) ? $row : [],
                ]);
            }
        }

        // Done?
        $total = (int) ($job['total'] ?? 0);
        $processed = (int) ($job['processed'] ?? 0);
        if ($total > 0 && $processed >= $total) {
            $job['done'] = true;
        }

        $this->setJob($jobId, $job);
        $this->runs->update($jobId, [
            'processed' => (int) ($job['processed'] ?? 0),
            'total' => (int) ($job['total'] ?? 0),
            'imported' => (int) ($job['imported'] ?? 0),
            'updated' => (int) ($job['updated'] ?? 0),
            'skipped' => (int) ($job['skipped'] ?? 0),
            'unsupported' => (int) ($job['unsupported'] ?? 0),
            'disabled' => (int) ($job['disabled'] ?? 0),
            'errors' => (int) ($job['errors'] ?? 0),
            'last_error' => (string) ($job['last_error'] ?? ''),
            'finished_at' => !empty($job['done']) ? current_time('mysql') : null,
        ]);

        if (!empty($job['done'])) {
            // Activity Log (best-effort).
            $this->logMigrationFinishedOnce($jobId, $job);
            wp_safe_redirect($this->pageUrl(['job' => $jobId, 'notice' => 'finished']));
            exit;
        }

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

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

        $jobId = isset($_POST['job']) ? sanitize_text_field((string) wp_unslash($_POST['job'])) : '';
        $force = !empty($_POST['force']);

        if ($jobId === '') {
            wp_safe_redirect($this->pageUrl(['notice' => 'error', 'msg' => 'Missing job']));
            exit;
        }

        $result = $this->bulkUndoMigration($jobId, $force);

        // Activity Log (best-effort).
        try {
            (new ActivityLogRepository())->insert(
                'redirects_migration_undo',
                'Redirect migration undo executed',
                [
                    'context' => 'redirects',
                    'object_type' => 'migration',
                    'object_key' => $jobId,
                    'meta' => [
                        'job_id' => $jobId,
                        'force' => $force ? 1 : 0,
                        'deleted' => (int) ($result['deleted'] ?? 0),
                        'restored' => (int) ($result['restored'] ?? 0),
                        'skipped' => (int) ($result['skipped'] ?? 0),
                        'already' => (int) ($result['already'] ?? 0),
                    ],
                ]
            );
        } catch (\Throwable $e) {
            // silent
        }

        wp_safe_redirect($this->pageUrl([
            'job' => $jobId,
            'notice' => 'undo_done',
            'deleted' => (int) ($result['deleted'] ?? 0),
            'restored' => (int) ($result['restored'] ?? 0),
            'skipped' => (int) ($result['skipped'] ?? 0),
            'already' => (int) ($result['already'] ?? 0),
        ]));
        exit;
    }

    /**
     * Bulk undo for redirects migration (one-click). Safe by default.
     *
     * @return array{deleted:int,restored:int,skipped:int,already:int}
     */
    private function bulkUndoMigration(string $jobId, bool $force = false): array
    {
        $svc = new RedirectMigrationUndoService($this->redirects, $this->logger);
        return $svc->undoJob($jobId, $force);
    }

    /**
     * Normalize a redirect row to comparable stable fields.
     *
     * @param array<string,mixed>|null $row
     * @return array<string,mixed>|null
     */
    private function redirectComparable(?array $row): ?array
    {
        if ($row === null) {
            return null;
        }

        $to = null;
        if (array_key_exists('to_url', $row)) {
            $to = $row['to_url'];
            if ($to !== null) {
                $to = (string) $to;
            }
        }

        return [
            'from_url' => isset($row['from_url']) ? (string) $row['from_url'] : '',
            'to_url' => $to,
            'match_type' => isset($row['match_type']) ? (string) $row['match_type'] : 'exact',
            'status_code' => isset($row['status_code']) ? (int) $row['status_code'] : 301,
            'enabled' => !empty($row['enabled']) ? 1 : 0,
        ];
    }

    /**
     * Log migration completion once (avoid duplicates).
     *
     * @param array<string,mixed> $job
     */
    private function logMigrationFinishedOnce(string $jobId, array $job): void
    {
        // Prevent duplicates: store a short-lived marker.
        $key = 'wpsn_redirects_migration_logged_' . $jobId;
        if (function_exists('get_transient') && get_transient($key)) {
            return;
        }
        if (function_exists('set_transient')) {
            set_transient($key, 1, DAY_IN_SECONDS);
        }

        // Register a unified undo run for this migration (so we can rollback from a single framework).
        try {
            $existing = UndoRuns::findLatest('redirects_migration', 'job_id', (string) $jobId);
            if (!is_array($existing)) {
                UndoRuns::record([
                    'type' => 'redirects_migration',
                    'label' => sprintf('Redirect migration (%s)', (string) ($job['source'] ?? 'unknown')),
                    'actor_user_id' => (int) get_current_user_id(),
                    'meta' => [
                        'job_id' => $jobId,
                        'source' => (string) ($job['source'] ?? ''),
                        'overwrite' => !empty($job['overwrite']) ? 1 : 0,
                        'include_disabled' => !empty($job['include_disabled']) ? 1 : 0,
                        'finished_at' => (string) ($job['finished_at'] ?? ''),
                    ],
                ]);
            }
        } catch (\Throwable $e) {
            // silent
        }

        try {
            (new ActivityLogRepository())->insert(
                'redirects_migration_finished',
                'Redirect migration finished',
                [
                    'context' => 'redirects',
                    'object_type' => 'migration',
                    'object_key' => $jobId,
                    'meta' => [
                        'job_id' => $jobId,
                        'source' => (string) ($job['source'] ?? ''),
                        'total' => (int) ($job['total'] ?? 0),
                        'processed' => (int) ($job['processed'] ?? 0),
                        'imported' => (int) ($job['imported'] ?? 0),
                        'updated' => (int) ($job['updated'] ?? 0),
                        'skipped' => (int) ($job['skipped'] ?? 0),
                        'unsupported' => (int) ($job['unsupported'] ?? 0),
                        'disabled' => (int) ($job['disabled'] ?? 0),
                        'errors' => (int) ($job['errors'] ?? 0),
                        'last_error' => (string) ($job['last_error'] ?? ''),
                    ],
                ]
            );
        } catch (\Throwable $e) {
            // silent
        }
    }

    /**
     * @return array<string, array{label: string, available: bool}>
     */
    private function availableSources(): array
    {
        $out = [
            'redirection' => [
                'label' => __('Redirection (plugin)', 'wp-seo-next'),
                'available' => $this->sourceIsAvailable('redirection'),
            ],
            'rankmath' => [
                'label' => __('Rank Math', 'wp-seo-next'),
                'available' => $this->sourceIsAvailable('rankmath'),
            ],
            'aioseo' => [
                'label' => __('AIOSEO / All in One SEO (Redirects)', 'wp-seo-next'),
                'available' => $this->sourceIsAvailable('aioseo'),
            ],
            'yoast' => [
                'label' => __('Yoast SEO Premium (PHP redirects)', 'wp-seo-next'),
                'available' => $this->sourceIsAvailable('yoast'),
            ],
            'seopress' => [
                'label' => __('SEOPress (Redirections)', 'wp-seo-next'),
                'available' => $this->sourceIsAvailable('seopress'),
            ],
        ];

        // Add row counts into labels (best-effort).
        foreach ($out as $key => $row) {
            if (!empty($row['available'])) {
                $n = $this->countSourceRows($key);
                if ($n > 0) {
                    $out[$key]['label'] = $row['label'] . ' (' . (string) $n . ')';
                }
            }
        }

        return $out;
    }

    private function sourceIsAvailable(string $source): bool
    {
        global $wpdb;

        if ($source === 'redirection') {
            $table = $wpdb->prefix . 'redirection_items';
            return $this->tableExists($table);
        }

        if ($source === 'rankmath') {
            $table = $wpdb->prefix . 'rank_math_redirections';
            return $this->tableExists($table);
        }

        if ($source === 'aioseo') {
            $table = $wpdb->prefix . 'aioseo_redirects';
            return $this->tableExists($table);
        }

        if ($source === 'yoast') {
            // Yoast SEO Premium stores PHP redirect rules in wp_options.
            $base  = get_option('wpseo-premium-redirects-base', null);
            $plain = get_option('wpseo-premium-redirects-export-plain', null);
            return ($base !== null && $base !== false) || ($plain !== null && $plain !== false);
        }

        if ($source === 'seopress') {
            // SEOPress stores redirections as a custom post type (seopress_404) with meta.
            // Detect by checking for any rows that look like redirections.
            $posts = $wpdb->posts;
            $pm    = $wpdb->postmeta;
            $sql = $wpdb->prepare(
                "SELECT 1 FROM {$posts} p INNER JOIN {$pm} pm ON pm.post_id = p.ID AND pm.meta_key = %s WHERE p.post_type = %s LIMIT 1",
                '_seopress_redirections_value',
                'seopress_404'
            );
            $v = $wpdb->get_var($sql);
            return !empty($v);
        }

        return false;
    }

    private function countSourceRows(string $source): int
    {
        global $wpdb;

        if ($source === 'redirection') {
            $table = $wpdb->prefix . 'redirection_items';
            if (!$this->tableExists($table)) {
                return 0;
            }
            $n = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
            return max(0, $n);
        }

        if ($source === 'rankmath') {
            $table = $wpdb->prefix . 'rank_math_redirections';
            if (!$this->tableExists($table)) {
                return 0;
            }
            $n = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
            return max(0, $n);
        }

        if ($source === 'aioseo') {
            $table = $wpdb->prefix . 'aioseo_redirects';
            if (!$this->tableExists($table)) {
                return 0;
            }
            $n = (int) $wpdb->get_var("SELECT COUNT(*) FROM {$table}");
            return max(0, $n);
        }

        if ($source === 'yoast') {
            $rules = $this->yoastRulesList();
            return count($rules);
        }

        if ($source === 'seopress') {
            $posts = $wpdb->posts;
            $pm    = $wpdb->postmeta;
            $sql = $wpdb->prepare(
                "SELECT COUNT(DISTINCT p.ID) FROM {$posts} p INNER JOIN {$pm} pm ON pm.post_id = p.ID AND pm.meta_key = %s WHERE p.post_type = %s",
                '_seopress_redirections_value',
                'seopress_404'
            );
            $n = (int) $wpdb->get_var($sql);
            return max(0, $n);
        }

        return 0;
    }

    /**
     * @return array<int, array<string, mixed>>
     */
    private function fetchSourceBatch(string $source, int $offset, int $limit): array
    {
        global $wpdb;
        $offset = max(0, $offset);
        $limit = max(1, min(500, $limit));

        if ($source === 'redirection') {
            $table = $wpdb->prefix . 'redirection_items';
            if (!$this->tableExists($table)) {
                return [];
            }
            $sql = $wpdb->prepare(
                "SELECT * FROM {$table} ORDER BY id ASC LIMIT %d OFFSET %d",
                $limit,
                $offset
            );
            $rows = $wpdb->get_results($sql, ARRAY_A);
            return is_array($rows) ? $rows : [];
        }

        if ($source === 'rankmath') {
            $table = $wpdb->prefix . 'rank_math_redirections';
            if (!$this->tableExists($table)) {
                return [];
            }
            $sql = $wpdb->prepare(
                "SELECT * FROM {$table} ORDER BY id ASC LIMIT %d OFFSET %d",
                $limit,
                $offset
            );
            $rows = $wpdb->get_results($sql, ARRAY_A);
            return is_array($rows) ? $rows : [];
        }

        if ($source === 'aioseo') {
            $table = $wpdb->prefix . 'aioseo_redirects';
            if (!$this->tableExists($table)) {
                return [];
            }
            $sql = $wpdb->prepare(
                "SELECT * FROM {$table} ORDER BY id ASC LIMIT %d OFFSET %d",
                $limit,
                $offset
            );
            $rows = $wpdb->get_results($sql, ARRAY_A);
            return is_array($rows) ? $rows : [];
        }

        if ($source === 'yoast') {
            $rules = $this->yoastRulesList();
            if (empty($rules)) {
                return [];
            }
            $batch = array_slice($rules, $offset, $limit, false);
            return is_array($batch) ? $batch : [];
        }

        if ($source === 'seopress') {
            $posts = $wpdb->posts;
            $pm    = $wpdb->postmeta;
            $sql = $wpdb->prepare(
                "SELECT p.ID, p.post_title, en.meta_value AS enabled, ty.meta_value AS type, val.meta_value AS url_to
                 FROM {$posts} p
                 INNER JOIN {$pm} val ON val.post_id = p.ID AND val.meta_key = %s
                 LEFT JOIN {$pm} en ON en.post_id = p.ID AND en.meta_key = %s
                 LEFT JOIN {$pm} ty ON ty.post_id = p.ID AND ty.meta_key = %s
                 WHERE p.post_type = %s
                 ORDER BY p.ID ASC
                 LIMIT %d OFFSET %d",
                '_seopress_redirections_value',
                '_seopress_redirections_enabled',
                '_seopress_redirections_type',
                'seopress_404',
                $limit,
                $offset
            );
            $rows = $wpdb->get_results($sql, ARRAY_A);
            return is_array($rows) ? $rows : [];
        }

        return [];
    }

    /**
     * Map a source row to 1..N WPSN rules.
     *
     * @param array<string, mixed> $row
     * @return array<int, array<string, mixed>>
     */
    private function mapSourceRowToRules(string $source, array $row, bool $includeDisabled): array
    {
        if ($source === 'redirection') {
            return $this->mapRedirectionRow($row, $includeDisabled);
        }
        if ($source === 'rankmath') {
            return $this->mapRankMathRow($row, $includeDisabled);
        }
        if ($source === 'aioseo') {
            return $this->mapAioseoRow($row, $includeDisabled);
        }
        if ($source === 'yoast') {
            return $this->mapYoastRow($row, $includeDisabled);
        }
        if ($source === 'seopress') {
            return $this->mapSeopressRow($row, $includeDisabled);
        }
        return [];
    }

    /**
     * @param array<string, mixed> $row
     * @return array<int, array<string, mixed>>
     */
    /**
     * Redirection plugin row mapping.
     *
     * @param array<string,mixed> $row
     * @return array<int, array<string,mixed>>
     */
    private function mapRedirectionRow(array $row, bool $includeDisabled): array
    {
        $enabled = ((int) ($row['status'] ?? 0) === 1);

        $rawFrom = isset($row['url']) ? (string) $row['url'] : '';
        $actionType = isset($row['action_type']) ? sanitize_key((string) $row['action_type']) : 'url';
        $to = isset($row['action_data']) ? (string) $row['action_data'] : '';

        $code = (int) ($row['action_code'] ?? 301);
        if ($code < 100 || $code > 599) {
            $code = 301;
        }

        $regex = ((int) ($row['regex'] ?? 0) === 1);
        if ($actionType !== 'url') {
            return [[
                '__unsupported' => true,
                '__reason' => 'action_' . $actionType,
                'from_url' => $regex ? RedirectMigrationRegex::normalizeImportedPattern($rawFrom) : $this->normalizeFromPath($rawFrom),
                'to_url' => $to,
                'match_type' => $regex ? 'regex' : 'exact',
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        if ($regex) {
            if (!$enabled && !$includeDisabled) {
                return [[
                    '__disabled' => true,
                    '__reason' => 'disabled',
                    'from_url' => RedirectMigrationRegex::normalizeImportedPattern($rawFrom),
                    'to_url' => $to,
                    'match_type' => 'regex',
                    'status_code' => $code,
                    'enabled' => 0,
                ]];
            }

            return $this->mapImportedRegexRule($rawFrom, $to, $code, $enabled);
        }

        $from = $this->normalizeFromPath($rawFrom);

        if ($from === '') {
            return [[
                '__unsupported' => true,
                '__reason' => 'missing_from',
                'from_url' => '',
                'to_url' => $to,
                'match_type' => 'exact',
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        if ($this->fromLooksUnsupported($from)) {
            return [[
                '__unsupported' => true,
                '__reason' => 'pattern',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => 'exact',
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        if (!$enabled && !$includeDisabled) {
            return [[
                '__disabled' => true,
                '__reason' => 'disabled',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => 'exact',
                'status_code' => $code,
                'enabled' => 0,
            ]];
        }

        // Redirection uses exact match by default (regex is handled above).
        $rule = [
            'from_url' => $from,
            'to_url' => $to !== '' ? $to : null,
            'match_type' => 'exact',
            'status_code' => $code,
            'enabled' => $enabled ? 1 : 0,
        ];

        // Allow empty to_url for 410/451.
        if (($code === 410 || $code === 451) && ($rule['to_url'] === null || $rule['to_url'] === '')) {
            $rule['to_url'] = null;
        }

        // Sanity: if to_url is missing for other codes, mark as unsupported.
        if (($code !== 410 && $code !== 451) && ($rule['to_url'] === null || $rule['to_url'] === '')) {
            return [[
                '__unsupported' => true,
                '__reason' => 'missing_to',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => 'exact',
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        return [$rule];
    }

    /**
     * Rank Math row mapping.
     *
     * @param array<string,mixed> $row
     * @return array<int, array<string,mixed>>
     */
    private function mapRankMathRow(array $row, bool $includeDisabled): array
    {
        $enabled = ((int) ($row['status'] ?? 0) === 1);

        $code = (int) ($row['header_code'] ?? 301);
        if ($code < 100 || $code > 599) {
            $code = 301;
        }

        $to = isset($row['url_to']) ? (string) $row['url_to'] : '';
        $to = trim($to);

        // Allow empty to_url for 410/451.
        if (($code === 410 || $code === 451) && $to === '') {
            $to = '';
        }

        // Some RM rules are serialized sources list.
        $sources = [];
        if (isset($row['sources'])) {
            $sources = maybe_unserialize($row['sources']);
        }
        if (!is_array($sources) || empty($sources)) {
            return [[
                '__unsupported' => true,
                '__reason' => 'missing_sources',
                'from_url' => '',
                'to_url' => $to,
                'match_type' => '',
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        $rules = [];

        foreach ($sources as $src) {
            if (!is_array($src)) {
                continue;
            }

            $rawPattern = isset($src['pattern']) ? (string) $src['pattern'] : '';
            $comparison = isset($src['comparison']) ? sanitize_key((string) $src['comparison']) : 'equals';
            $isRegex = ($comparison !== '' && strpos($comparison, 'regex') !== false);

            if (!$enabled && !$includeDisabled) {
                $rules[] = [
                    '__disabled' => true,
                    '__reason' => 'disabled',
                    'from_url' => $isRegex ? RedirectMigrationRegex::normalizeImportedPattern($rawPattern) : $this->normalizeFromPath($rawPattern),
                    'to_url' => $to,
                    'match_type' => $isRegex ? 'regex' : '',
                    'status_code' => $code,
                    'enabled' => 0,
                ];
                continue;
            }

            if ($isRegex) {
                foreach ($this->mapImportedRegexRule($rawPattern, $to, $code, $enabled) as $rule) {
                    $rules[] = $rule;
                }
                continue;
            }

            $pattern = $this->normalizeFromPath($rawPattern);
            if ($this->fromLooksUnsupported($pattern)) {
                $rules[] = [
                    '__unsupported' => true,
                    '__reason' => 'pattern',
                    'from_url' => $pattern,
                    'to_url' => $to,
                    'match_type' => '',
                    'status_code' => $code,
                    'enabled' => $enabled ? 1 : 0,
                ];
                continue;
            }

            // Rank Math comparisons we support: equals (exact), start (prefix).
            $matchType = '';
            if ($comparison === 'equals') {
                $matchType = 'exact';
            } elseif ($comparison === 'start') {
                $matchType = 'prefix';
            } else {
                $rules[] = [
                    '__unsupported' => true,
                    '__reason' => 'comparison_' . $comparison,
                    'from_url' => $pattern,
                    'to_url' => $to,
                    'match_type' => $comparison,
                    'status_code' => $code,
                    'enabled' => $enabled ? 1 : 0,
                ];
                continue;
            }

            // If to is missing for non-410/451, mark unsupported.
            if (($code !== 410 && $code !== 451) && $to === '') {
                $rules[] = [
                    '__unsupported' => true,
                    '__reason' => 'missing_to',
                    'from_url' => $pattern,
                    'to_url' => $to,
                    'match_type' => $matchType,
                    'status_code' => $code,
                    'enabled' => $enabled ? 1 : 0,
                ];
                continue;
            }

            $rules[] = [
                'from_url' => $pattern,
                'to_url' => $to !== '' ? $to : null,
                'match_type' => $matchType,
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ];
        }

        return $rules;
    }

    /**
     * AIOSEO Redirects row mapping.
     *
     * We keep this intentionally defensive because AIOSEO has changed schema
     * between major versions and installations.
     *
     * @param array<string,mixed> $row
     * @return array<int, array<string,mixed>>
     */
    private function mapAioseoRow(array $row, bool $includeDisabled): array
    {
        $pickString = static function (array $row, array $keys): string {
            foreach ($keys as $k) {
                if (!is_string($k)) {
                    continue;
                }
                if (!array_key_exists($k, $row)) {
                    continue;
                }
                $v = $row[$k];
                if (is_string($v)) {
                    $t = trim($v);
                    if ($t !== '') {
                        return $t;
                    }
                }
            }
            return '';
        };

        $enabled = true;
        foreach (['enabled', 'is_enabled', 'active', 'is_active', 'status'] as $k) {
            if (!array_key_exists($k, $row)) {
                continue;
            }
            $v = $row[$k];
            if (is_bool($v)) {
                $enabled = (bool) $v;
                break;
            }
            if (is_numeric($v)) {
                $enabled = ((int) $v === 1);
                break;
            }
            if (is_string($v)) {
                $vv = strtolower(trim($v));
                if (in_array($vv, ['0', 'no', 'false', 'disabled', 'inactive'], true)) {
                    $enabled = false;
                }
                if (in_array($vv, ['1', 'yes', 'true', 'enabled', 'active'], true)) {
                    $enabled = true;
                }
                break;
            }
        }

        // HTTP status code.
        $code = 301;
        foreach (['status_code', 'http_code', 'code', 'header_code', 'type', 'redirect_type'] as $k) {
            if (!array_key_exists($k, $row)) {
                continue;
            }
            $v = $row[$k];
            if (is_numeric($v)) {
                $maybe = (int) $v;
                if ($maybe >= 100 && $maybe <= 599) {
                    $code = $maybe;
                    break;
                }
            }
            if (is_string($v) && is_numeric($v)) {
                $maybe = (int) $v;
                if ($maybe >= 100 && $maybe <= 599) {
                    $code = $maybe;
                    break;
                }
            }
        }

        $from = $pickString($row, [
            'source_url',
            'source',
            'sourceUrl',
            'source_uri',
            'from_url',
            'from',
            'old_url',
            'url_from',
            'url',
        ]);
        $to = $pickString($row, [
            'target_url',
            'target',
            'targetUrl',
            'target_uri',
            'to_url',
            'to',
            'new_url',
            'url_to',
            'destination',
            'url_to',
        ]);

        $matchType = 'exact';

        // Detect regex/unsupported patterns.
        $regexFlag = false;
        foreach (['regex', 'is_regex', 'use_regex', 'match_regex'] as $k) {
            if (!array_key_exists($k, $row)) {
                continue;
            }
            $v = $row[$k];
            if (is_bool($v) && $v) {
                $regexFlag = true;
                break;
            }
            if (is_numeric($v) && (int) $v === 1) {
                $regexFlag = true;
                break;
            }
            if (is_string($v) && in_array(strtolower(trim($v)), ['1', 'yes', 'true'], true)) {
                $regexFlag = true;
                break;
            }
        }

        $matchRaw = $pickString($row, ['match_type', 'matchType', 'match', 'source_type']);
        $matchRaw = strtolower(trim($matchRaw));
        if ($matchRaw !== '') {
            if (in_array($matchRaw, ['regex', 'regexp'], true)) {
                $regexFlag = true;
            } elseif (in_array($matchRaw, ['prefix', 'starts_with', 'startswith', 'begins', 'begin'], true)) {
                $matchType = 'prefix';
            } elseif (in_array($matchRaw, ['contains', 'partial', 'substring'], true)) {
                return [[
                    '__unsupported' => true,
                    '__reason' => 'contains',
                    'from_url' => $this->normalizeFromPath($from),
                    'to_url' => $to,
                    'match_type' => $matchRaw,
                    'status_code' => $code,
                    'enabled' => $enabled ? 1 : 0,
                ]];
            }
        }

        if ($regexFlag) {
            if (!$enabled && !$includeDisabled) {
                return [[
                    '__disabled' => true,
                    '__reason' => 'disabled',
                    'from_url' => RedirectMigrationRegex::normalizeImportedPattern($from),
                    'to_url' => $to,
                    'match_type' => 'regex',
                    'status_code' => $code,
                    'enabled' => 0,
                ]];
            }

            return $this->mapImportedRegexRule($from, $to, $code, $enabled);
        }

        $from = $this->normalizeFromPath($from);

        if ($from === '') {
            return [[
                '__unsupported' => true,
                '__reason' => 'missing_from',
                'from_url' => '',
                'to_url' => $to,
                'match_type' => $matchType,
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        if ($this->fromLooksUnsupported($from)) {
            return [[
                '__unsupported' => true,
                '__reason' => 'pattern',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => $matchType,
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        if (!$enabled && !$includeDisabled) {
            return [[
                '__disabled' => true,
                '__reason' => 'disabled',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => $matchType,
                'status_code' => $code,
                'enabled' => 0,
            ]];
        }

        $to = trim((string) $to);

        // Allow empty to_url for 410/451.
        if (($code !== 410 && $code !== 451) && $to === '') {
            return [[
                '__unsupported' => true,
                '__reason' => 'missing_to',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => $matchType,
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        return [[
            'from_url' => $from,
            'to_url' => $to !== '' ? $to : null,
            'match_type' => $matchType,
            'status_code' => $code,
            'enabled' => $enabled ? 1 : 0,
        ]];
    }

    /**
     * Yoast SEO Premium PHP redirects mapping.
     *
     * @param array<string,mixed> $row
     * @return array<int, array<string,mixed>>
     */
    private function mapYoastRow(array $row, bool $includeDisabled): array
    {
        // Yoast Premium stores redirects in serialized options.
        // Most common keys: origin, url, format, type.
        $from = isset($row['origin']) ? (string) $row['origin'] : '';
        $to = isset($row['url']) ? (string) $row['url'] : '';
        $format = isset($row['format']) ? sanitize_key((string) $row['format']) : '';
        $code = isset($row['type']) ? (int) $row['type'] : 301;

        if ($code < 100 || $code > 599) {
            $code = 301;
        }

        // Yoast has no enabled flag; treat as enabled.
        $enabled = true;

        if ($format === 'regex') {
            return $this->mapImportedRegexRule($from, $to, $code, $enabled);
        }

        $from = $this->normalizeFromPath($from);

        if ($from === '') {
            return [[
                '__unsupported' => true,
                '__reason' => 'missing_from',
                'from_url' => '',
                'to_url' => $to,
                'match_type' => '',
                'status_code' => $code,
                'enabled' => 1,
            ]];
        }

        // Yoast origin may include wildcards/regex-like patterns.
        if ($this->fromLooksUnsupported($from)) {
            return [[
                '__unsupported' => true,
                '__reason' => 'pattern',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => '',
                'status_code' => $code,
                'enabled' => 1,
            ]];
        }

        // Target is optional for 410/451.
        if (($code !== 410 && $code !== 451) && trim($to) === '') {
            return [[
                '__unsupported' => true,
                '__reason' => 'missing_to',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => '',
                'status_code' => $code,
                'enabled' => 1,
            ]];
        }

        $matchType = 'exact';

        if (!$enabled && !$includeDisabled) {
            return [[
                '__disabled' => true,
                '__reason' => 'disabled',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => $matchType,
                'status_code' => $code,
                'enabled' => 0,
            ]];
        }

        return [[
            'from_url' => $from,
            'to_url' => trim($to) !== '' ? $to : null,
            'match_type' => $matchType,
            'status_code' => $code,
            'enabled' => $enabled ? 1 : 0,
        ]];
    }

    /**
     * SEOPress redirections mapping.
     *
     * @param array<string,mixed> $row
     * @return array<int, array<string,mixed>>
     */
    private function mapSeopressRow(array $row, bool $includeDisabled): array
    {
        // SEOPress redirections are stored as CPT "seopress_404" with meta keys.
        $enabledRaw = isset($row['enabled']) ? (string) $row['enabled'] : '';
        $enabledRaw = strtolower(trim($enabledRaw));
        $enabled = in_array($enabledRaw, ['yes', '1', 'true', 'enabled'], true);

        $from = isset($row['post_title']) ? (string) $row['post_title'] : '';
        $to   = isset($row['url_to']) ? (string) $row['url_to'] : '';
        $code = isset($row['type']) ? (int) $row['type'] : 301;

        if ($code < 100 || $code > 599) {
            $code = 301;
        }

        $from = $this->normalizeFromPath($from);
        $to = trim((string) $to);

        if (!$enabled && !$includeDisabled) {
            return [[
                '__disabled' => true,
                '__reason' => 'disabled',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => 'exact',
                'status_code' => $code,
                'enabled' => 0,
            ]];
        }

        if ($from === '') {
            return [[
                '__unsupported' => true,
                '__reason' => 'missing_from',
                'from_url' => '',
                'to_url' => $to,
                'match_type' => 'exact',
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        if ($this->fromLooksUnsupported($from)) {
            return [[
                '__unsupported' => true,
                '__reason' => 'pattern',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => 'exact',
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        if (!in_array($code, [410, 451], true) && $to === '') {
            return [[
                '__unsupported' => true,
                '__reason' => 'missing_to',
                'from_url' => $from,
                'to_url' => $to,
                'match_type' => 'exact',
                'status_code' => $code,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        return [[
            'from_url' => $from,
            'to_url' => $to !== '' ? $to : null,
            'match_type' => 'exact',
            'status_code' => $code,
            'enabled' => $enabled ? 1 : 0,
        ]];
    }


    private function regexImportsEnabled(): bool
    {
        $settings = (new RedirectsSettingsRepository())->getAll();
        return !empty($settings['enable_regex']);
    }

    /**
     * @return array<int,array<string,mixed>>
     */
    private function mapImportedRegexRule(string $pattern, string $destination, int $statusCode, bool $enabled): array
    {
        $normalizedPattern = RedirectMigrationRegex::normalizeImportedPattern($pattern);

        if (!$this->regexImportsEnabled()) {
            return [[
                '__unsupported' => true,
                '__reason' => 'regex_disabled',
                '__message' => __('Regex imports are disabled until Advanced regex redirects are enabled in WP SEO Next.', 'wp-seo-next'),
                'from_url' => $normalizedPattern,
                'to_url' => trim($destination),
                'match_type' => 'regex',
                'status_code' => $statusCode,
                'enabled' => $enabled ? 1 : 0,
            ]];
        }

        $result = RedirectMigrationRegex::buildRule($pattern, $destination, $statusCode, $enabled);
        if (!empty($result['ok']) && !empty($result['rule']) && is_array($result['rule'])) {
            return [$result['rule']];
        }

        return [[
            '__unsupported' => true,
            '__reason' => isset($result['reason']) ? sanitize_key((string) $result['reason']) : 'regex_invalid',
            '__message' => isset($result['message']) ? (string) $result['message'] : __('Regex rule could not be imported safely.', 'wp-seo-next'),
            '__warnings' => isset($result['warnings']) && is_array($result['warnings']) ? array_values(array_map('strval', $result['warnings'])) : [],
            'from_url' => isset($result['pattern']) ? (string) $result['pattern'] : $normalizedPattern,
            'to_url' => trim($destination),
            'match_type' => 'regex',
            'status_code' => $statusCode,
            'enabled' => $enabled ? 1 : 0,
        ]];
    }

    /**
     * Normalize various input formats (path or full URL) into a WPSN from_url path.
     */
    private function normalizeFromPath(string $from): string
    {
        $from = trim($from);
        if ($from === '') {
            return '';
        }

        // If user/plugin stored full URL - take its path.
        $maybePath = wp_parse_url($from, PHP_URL_PATH);
        if (is_string($maybePath) && $maybePath !== '') {
            if (preg_match('#^https?://#i', $from)) {
                $from = $maybePath;
            }
        }

        $from = rawurldecode($from);
        $from = ltrim($from);
        if ($from !== '' && $from[0] !== '/') {
            $from = '/' . $from;
        }

        // Strip query and fragment.
        $pathOnly = wp_parse_url($from, PHP_URL_PATH);
        if (is_string($pathOnly) && $pathOnly !== '') {
            $from = $pathOnly;
        }

        if ($from === '') {
            $from = '/';
        }

        return $from;
    }

    /**
     * Very small heuristic to skip rules we cannot support:
     *  - regex patterns
     *  - query-string specific redirects (we match paths only)
     */
    private function fromLooksUnsupported(string $from): bool
    {
        $from = trim($from);
        if ($from === '') {
            return true;
        }

        // We match only paths (no query/fragment).
        if (strpos($from, '?') !== false || strpos($from, '#') !== false) {
            return true;
        }

        // Regex-like characters.
        if (preg_match('/[\^\$\[\]\(\)\{\}\|\\\\\*]/', $from)) {
            return true;
        }

        return false;
    }

    private function tableExists(string $table): bool
    {
        global $wpdb;
        $like = $wpdb->esc_like($table);
        $sql = $wpdb->prepare('SHOW TABLES LIKE %s', $like);
        $found = $wpdb->get_var($sql);
        return is_string($found) && $found === $table;
    }

    private function sanitizeRedirectRow($row): ?array
    {
        if (!is_array($row)) {
            return null;
        }

        $to = null;
        if (array_key_exists('to_url', $row)) {
            $to = $row['to_url'];
            if (!is_string($to) && $to !== null) {
                $to = null;
            }
        }

        $lastHit = null;
        if (array_key_exists('last_hit', $row)) {
            $lastHit = $row['last_hit'];
            if (!is_string($lastHit) && $lastHit !== null) {
                $lastHit = null;
            }
        }

        return [
            'id' => (int) ($row['id'] ?? 0),
            'from_url' => isset($row['from_url']) ? (string) $row['from_url'] : '',
            'to_url' => $to,
            'match_type' => isset($row['match_type']) ? (string) $row['match_type'] : 'exact',
            'status_code' => isset($row['status_code']) ? (int) $row['status_code'] : 301,
            'enabled' => !empty($row['enabled']),
            'hits' => isset($row['hits']) ? (int) $row['hits'] : 0,
            'last_hit' => $lastHit,
            'created_at' => isset($row['created_at']) ? (string) $row['created_at'] : '',
            'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : '',
        ];
    }

    /**
     * Persist unified revision entry for redirects changes during migration.
     *
     * @param array<string,mixed>|null $beforeRow
     * @param array<string,mixed>|null $afterRow
     * @param array<string,mixed> $meta
     */
    private function maybeLogRedirectRevision(string $action, int $redirectId, ?array $beforeRow, ?array $afterRow, array $meta = []): void
    {
        if ($redirectId <= 0) {
            return;
        }

        try {
            RevisionsInstaller::maybeInstall();
            $revRepo = new RevisionsRepository($this->logger);

            $before = ['redirect' => $this->sanitizeRedirectRow($beforeRow)];
            $after  = ['redirect' => $this->sanitizeRedirectRow($afterRow)];

            // Avoid noise: skip no-op updates.
            if ($before['redirect'] === $after['redirect'] && !in_array($action, ['create', 'delete'], true)) {
                return;
            }

            $sub = '';
            if (is_array($after['redirect']) && !empty($after['redirect']['match_type'])) {
                $sub = (string) $after['redirect']['match_type'];
            } elseif (is_array($before['redirect']) && !empty($before['redirect']['match_type'])) {
                $sub = (string) $before['redirect']['match_type'];
            }

            $revRepo->insert([
                'object_type' => 'redirect',
                'object_id' => $redirectId,
                'object_subtype' => $sub,
                'action' => $action,
                'source' => 'redirects_migration',
                'actor_user_id' => (int) get_current_user_id(),
                'before' => $before,
                'after' => $after,
                'meta' => $meta,
            ]);
        } catch (\Throwable $e) {
            // Silent by design.
        }
    }

    /**
     * When the transient expires, we can still show progress using the DB run summary.
     *
     * @param array<string,mixed> $run
     * @return array<string,mixed>
     */
    private function normalizeRunToJob(array $run): array
    {
        $jobId = isset($run['job_id']) ? sanitize_text_field((string) $run['job_id']) : '';

        return [
            'id' => $jobId,
            'source' => isset($run['source']) ? sanitize_key((string) $run['source']) : '',
            'overwrite' => !empty($run['overwrite']),
            'include_disabled' => !empty($run['include_disabled']),
            // offset is used as source-row cursor.
            'offset' => (int) ($run['processed'] ?? 0),
            'processed' => (int) ($run['processed'] ?? 0),
            'total' => (int) ($run['total'] ?? 0),
            'imported' => (int) ($run['imported'] ?? 0),
            'updated' => (int) ($run['updated'] ?? 0),
            'skipped' => (int) ($run['skipped'] ?? 0),
            'unsupported' => (int) ($run['unsupported'] ?? 0),
            'disabled' => (int) ($run['disabled'] ?? 0),
            'errors' => (int) ($run['errors'] ?? 0),
            'done' => !empty($run['finished_at']),
            'last_error' => isset($run['last_error']) ? sanitize_text_field((string) $run['last_error']) : '',
            'started_at' => isset($run['started_at']) ? (string) $run['started_at'] : '',
            'finished_at' => isset($run['finished_at']) ? (string) $run['finished_at'] : '',
        ];
    }

    /**
     * @return array<string, mixed>|null
     */
    private function getJob(string $jobId): ?array
    {
        $job = get_transient(self::TRANSIENT_PREFIX . $jobId);
        return is_array($job) ? $job : null;
    }

    /**
     * @param array<string, mixed> $job
     */
    private function setJob(string $jobId, array $job): void
    {
        // 6 hours should be plenty even for large sets.
        set_transient(self::TRANSIENT_PREFIX . $jobId, $job, 6 * HOUR_IN_SECONDS);
    }

    private function deleteJob(string $jobId): void
    {
        delete_transient(self::TRANSIENT_PREFIX . $jobId);
    }
}
