<?php
/**
 * RankForge Migration — import SEO data from Yoast, Rank Math, AIOSEO, SEOPress
 * Works directly with the database — no competitor plugin code is loaded or required.
 */

if (!defined('ABSPATH')) exit;

class RANKFORGE_Migration {

    private static $instance = null;

    public static function instance(): self {
        if (!self::$instance) self::$instance = new self();
        return self::$instance;
    }

    public function __construct() {
        if (!is_admin()) return;
        add_action('admin_menu', [$this, 'add_menu'], 20);
        add_action('wp_ajax_rankforge_migration_run', [$this, 'ajax_run']);
    }

    public function add_menu(): void {
        add_submenu_page(
            'rankforge',
            __('Migration', 'rankforge'),
            __('Migration', 'rankforge'),
            'manage_options',
            'rankforge-migration',
            [$this, 'render_page']
        );
    }


    // Detection


    private function detect_sources(): array {
        global $wpdb;
        $sources = [];

        if (!function_exists('is_plugin_active')) {
            require_once ABSPATH . 'wp-admin/includes/plugin.php';
        }

        // Yoast SEO — detect by plugin activation OR by leftover data (works
        // after uninstall too, per tester had Yoast active but no
        // posts had meta yet, so a postmeta-only check returned 0).
        $yoast_active = is_plugin_active('wordpress-seo/wp-seo.php') || is_plugin_active('wordpress-seo-premium/wp-seo-premium.php');
        $yoast_options = get_option('wpseo') || get_option('wpseo_titles');
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        $yoast_count = (int) $wpdb->get_var(
            "SELECT COUNT(DISTINCT pm.post_id)
             FROM {$wpdb->postmeta} pm
             JOIN {$wpdb->posts} p ON p.ID = pm.post_id
             WHERE pm.meta_key IN (
                 '_yoast_wpseo_title',
                 '_yoast_wpseo_metadesc',
                 '_yoast_wpseo_focuskw',
                 '_yoast_wpseo_meta-robots-noindex',
                 '_yoast_wpseo_meta-robots-nofollow',
                 '_yoast_wpseo_canonical',
                 '_yoast_wpseo_opengraph-title',
                 '_yoast_wpseo_opengraph-description',
                 '_yoast_wpseo_opengraph-image',
                 '_yoast_wpseo_twitter-title',
                 '_yoast_wpseo_twitter-description'
             )
             AND pm.meta_value IS NOT NULL
             AND pm.meta_value != ''
             AND p.post_status != 'trash'"
        );
        if ($yoast_active || $yoast_options || $yoast_count > 0) {
            $yoast_titles_opt = get_option('wpseo_titles');
            $yoast_has_globals = is_array($yoast_titles_opt) && !empty($yoast_titles_opt);
            $sources['yoast'] = [
                'name'  => 'Yoast SEO',
                'count' => $yoast_count,
                'has_globals' => $yoast_has_globals,
                'note'  => __('Migrates: title, description, focus keyword, canonical, robots, social fields, plus global title/description templates (post types, taxonomies, separator)', 'rankforge'),
            ];
        }

        // Rank Math
        $rm_active  = is_plugin_active('seo-by-rank-math/rank-math.php') || is_plugin_active('seo-by-rank-math-pro/rank-math-pro.php');
        $rm_titles  = get_option('rank-math-options-titles');
        $rm_options = (bool) (get_option('rank-math-options-general') || $rm_titles);
        $rm_has_globals = is_array($rm_titles) && $this->rankmath_titles_has_globals($rm_titles);
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        $rm_count   = (int) $wpdb->get_var(
            "SELECT COUNT(DISTINCT pm.post_id)
             FROM {$wpdb->postmeta} pm
             JOIN {$wpdb->posts} p ON p.ID = pm.post_id
             WHERE pm.meta_key IN (
                 'rank_math_title',
                 'rank_math_description',
                 'rank_math_focus_keyword',
                 'rank_math_robots',
                 'rank_math_canonical_url',
                 'rank_math_facebook_title',
                 'rank_math_facebook_description',
                 'rank_math_facebook_image',
                 'rank_math_twitter_title',
                 'rank_math_twitter_description'
             )
             AND pm.meta_value IS NOT NULL
             AND pm.meta_value != ''
             AND p.post_status != 'trash'"
        );
        if ($rm_active || $rm_options || $rm_count > 0) {
            $sources['rankmath'] = [
                'name'  => 'Rank Math',
                'count' => $rm_count,
                // signal to the UI that globals are available
                // for migration too, same shape as the Yoast entry.
                'has_globals' => $rm_has_globals,
                'note'  => __('Migrates: title, description, focus keyword, canonical, robots, social fields, plus global title/description templates (post types, taxonomies, separator)', 'rankforge'),
            ];
        }

        // All in One SEO (v4 table + v3 postmeta)
        $aioseo_active  = is_plugin_active('all-in-one-seo-pack/all_in_one_seo_pack.php') || is_plugin_active('all-in-one-seo-pack-pro/all_in_one_seo_pack.php');
        $aioseo_options = get_option('aioseo_options') || get_option('aioseop_options');
        $aioseo_table   = $wpdb->prefix . 'aioseo_posts';
        $aioseo_count   = 0;
        $aioseo_label   = 'All in One SEO';
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        if ($wpdb->get_var("SHOW TABLES LIKE '{$aioseo_table}'") === $aioseo_table) {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
            $aioseo_count = (int) $wpdb->get_var(
                "SELECT COUNT(DISTINCT a.post_id)
                 FROM {$aioseo_table} a
                 JOIN {$wpdb->posts} p ON p.ID = a.post_id
                 WHERE {$this->aioseo_v4_supported_payload_where('a')}
                 AND p.post_status != 'trash'"
            );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
        } else {
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
            $aioseo_count = (int) $wpdb->get_var(
                "SELECT COUNT(DISTINCT post_id) FROM {$wpdb->postmeta}
                 WHERE meta_key = '_aioseop_title'"
            );
            if ($aioseo_count > 0) $aioseo_label = 'All in One SEO (v3)';
        }
        if ($aioseo_active || $aioseo_options || $aioseo_count > 0) {
            $sources['aioseo'] = [
                'name'  => $aioseo_label,
                'count' => $aioseo_count,
                'note'  => __('Migrates: title, description, focus keyword, canonical, robots, and social fields where the source stores them', 'rankforge'),
            ];
        }

        // SEOPress
        $sp_active  = is_plugin_active('wp-seopress/seopress.php') || is_plugin_active('wp-seopress-pro/seopress-pro.php');
        $sp_options = get_option('seopress_titles_option_name') || get_option('seopress_pro_option_name');
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        $sp_count   = (int) $wpdb->get_var(
            "SELECT COUNT(DISTINCT pm.post_id)
             FROM {$wpdb->postmeta} pm
             JOIN {$wpdb->posts} p ON p.ID = pm.post_id
             WHERE pm.meta_key IN (
                 '_seopress_titles_title',
                 '_seopress_titles_desc',
                 '_seopress_analysis_target_kw',
                 '_seopress_robots_index',
                 '_seopress_robots_follow',
                 '_seopress_robots_canonical',
                 '_seopress_social_fb_title',
                 '_seopress_social_fb_desc',
                 '_seopress_social_fb_img',
                 '_seopress_social_twitter_title',
                 '_seopress_social_twitter_desc',
                 '_seopress_redirections_value',
                 '_seopress_redirections_type'
             )
             AND pm.meta_value IS NOT NULL
             AND pm.meta_value != ''
             AND p.post_status != 'trash'"
        );
        if ($sp_active || $sp_options || $sp_count > 0) {
            $sources['seopress'] = [
                'name'  => 'SEOPress',
                'count' => $sp_count,
                'note'  => __('Migrates: title, description, focus keyword, canonical, robots, social fields, redirects', 'rankforge'),
            ];
        }

        return $sources;
    }


    // Migration logic


    private function migrate(string $source, bool $overwrite): array {
        switch ($source) {
            case 'yoast':    return $this->migrate_yoast($overwrite);
            case 'rankmath': return $this->migrate_rankmath($overwrite);
            case 'aioseo':   return $this->migrate_aioseo($overwrite);
            case 'seopress': return $this->migrate_seopress($overwrite);
            default:         return ['error' => 'Unknown source'];
        }
    }

    private function migrate_yoast(bool $overwrite): array {
        global $wpdb;
        $migrated = $skipped = $errors = 0;
        $log = [];

        // Global settings first — per-post-type title/description templates,
        // taxonomy archive templates, separator. Matches what the tester
        // configured under Yoast → Settings → Content Types .
        $globals = $this->migrate_yoast_globals($overwrite);
        if (!empty($globals['log'])) {
            $log = array_merge($log, $globals['log']);
        }

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        $posts = $wpdb->get_results(
            "SELECT DISTINCT pm.post_id, p.post_title
             FROM {$wpdb->postmeta} pm
             JOIN {$wpdb->posts} p ON p.ID = pm.post_id
             WHERE pm.meta_key IN (
                 '_yoast_wpseo_title',
                 '_yoast_wpseo_metadesc',
                 '_yoast_wpseo_focuskw',
                 '_yoast_wpseo_meta-robots-noindex',
                 '_yoast_wpseo_meta-robots-nofollow',
                 '_yoast_wpseo_canonical',
                 '_yoast_wpseo_opengraph-title',
                 '_yoast_wpseo_opengraph-description',
                 '_yoast_wpseo_opengraph-image',
                 '_yoast_wpseo_twitter-title',
                 '_yoast_wpseo_twitter-description'
             )
             AND pm.meta_value IS NOT NULL
             AND pm.meta_value != ''
             AND p.post_status != 'trash'
             ORDER BY pm.post_id ASC"
        );

        foreach ($posts as $row) {
            $pid = (int) $row->post_id;

            if (!$overwrite && get_post_meta($pid, '_rankforge_meta_title', true)) {
                $skipped++;
                continue;
            }

            $title = get_post_meta($pid, '_yoast_wpseo_title', true);
            $desc  = get_post_meta($pid, '_yoast_wpseo_metadesc', true);
            $kw    = get_post_meta($pid, '_yoast_wpseo_focuskw', true);

            $title = $this->resolve_seo_vars($title, $pid, 'yoast');
            $desc  = $this->resolve_seo_vars($desc, $pid, 'yoast');

            if ($title) update_post_meta($pid, '_rankforge_meta_title', sanitize_text_field($title));
            if ($desc)  update_post_meta($pid, '_rankforge_meta_description', sanitize_text_field($desc));
            if ($kw)    update_post_meta($pid, '_rankforge_focus_keyword', sanitize_text_field($kw));
            $this->migrate_supported_post_fields($pid, [
                'noindex' => $this->is_truthy_flag(get_post_meta($pid, '_yoast_wpseo_meta-robots-noindex', true)),
                'nofollow' => $this->is_truthy_flag(get_post_meta($pid, '_yoast_wpseo_meta-robots-nofollow', true)),
                'canonical_url' => get_post_meta($pid, '_yoast_wpseo_canonical', true),
                'og_title' => get_post_meta($pid, '_yoast_wpseo_opengraph-title', true),
                'og_description' => get_post_meta($pid, '_yoast_wpseo_opengraph-description', true),
                'og_image' => get_post_meta($pid, '_yoast_wpseo_opengraph-image', true) ?: get_post_meta($pid, '_yoast_wpseo_twitter-image', true),
                'twitter_title' => get_post_meta($pid, '_yoast_wpseo_twitter-title', true),
                'twitter_description' => get_post_meta($pid, '_yoast_wpseo_twitter-description', true),
            ]);

            $migrated++;
            $log[] = sprintf('#%d %s', $pid, esc_html(mb_strimwidth($row->post_title, 0, 60, '…')));
        }

        return compact('migrated', 'skipped', 'errors', 'log');
    }

    /**
 * Migrate Yoast global title/description templates .
 *
 * Yoast stores per-post-type and per-taxonomy-archive templates in the
 * `wpseo_titles` option. RankForge reads them back from
 * `rankforge_title_templates` (post types) and `rankforge_tax_templates`
 * (per-term, though Yoast templates are per-taxonomy-archive). We also
 * map Yoast's separator preset (`sc-pipe`, `sc-dash`, …) to the literal
 * character RankForge uses in `rankforge_title_separator`.
 *
 * Variables are rewritten from `%%name%%` to `{{name}}` so the template
 * stays a template — resolved at render time per post, not hardcoded.
 */
    private function migrate_yoast_globals(bool $overwrite): array {
        $log = [];
        $wpseo_titles = get_option('wpseo_titles');
        if (!is_array($wpseo_titles) || empty($wpseo_titles)) {
            return ['log' => $log];
        }

        // --- Separator --------------------------------------------------------
        $existing_sep = get_option('rankforge_title_separator', '');
        if ($overwrite || $existing_sep === '' || $existing_sep === '|') {
            $sep_map = [
                'sc-dash'   => '-',
                'sc-ndash'  => '–',
                'sc-mdash'  => '—',
                'sc-colon'  => ':',
                'sc-middot' => '·',
                'sc-bull'   => '•',
                'sc-star'   => '*',
                'sc-smstar' => '⋆',
                'sc-pipe'   => '|',
                'sc-tilde'  => '~',
                'sc-laquo'  => '«',
                'sc-raquo'  => '»',
                'sc-lt'     => '<',
                'sc-gt'     => '>',
            ];
            if (!empty($wpseo_titles['separator']) && isset($sep_map[$wpseo_titles['separator']])) {
                update_option('rankforge_title_separator', $sep_map[$wpseo_titles['separator']]);
                /* translators: %s: title separator character */
                $log[] = sprintf(__('Separator: %s', 'rankforge'), $sep_map[$wpseo_titles['separator']]);
            }
        }

        // --- Post-type templates ---------------------------------------------
        $pt_templates = $overwrite ? [] : get_option('rankforge_title_templates', []);
        if (!is_array($pt_templates)) $pt_templates = [];

        $pt_count = 0;
        foreach (get_post_types(['public' => true], 'names') as $pt) {
            $title_raw = $wpseo_titles['title-' . $pt]    ?? '';
            $desc_raw  = $wpseo_titles['metadesc-' . $pt] ?? '';
            if ($title_raw === '' && $desc_raw === '') continue;

            // Don't clobber existing settings unless overwrite is on.
            if (!$overwrite && !empty($pt_templates[$pt]['title']) && !empty($pt_templates[$pt]['description'])) {
                continue;
            }

            $title = $this->yoast_template_to_rankforge($title_raw);
            $desc  = $this->yoast_template_to_rankforge($desc_raw);

            if ($title !== '' && ($overwrite || empty($pt_templates[$pt]['title']))) {
                $pt_templates[$pt]['title'] = $title;
            }
            if ($desc !== '' && ($overwrite || empty($pt_templates[$pt]['description']))) {
                $pt_templates[$pt]['description'] = $desc;
            }
            $pt_count++;
            /* translators: %s: post type name */
            $log[] = sprintf(__('Post type: %s', 'rankforge'), esc_html($pt));
        }
        if ($pt_count > 0) {
            update_option('rankforge_title_templates', $pt_templates);
        }

        // --- Taxonomy templates ----------------------------------------------
        // Yoast stores these per-taxonomy (archive), RankForge stores per-term.
        // We write the Yoast archive template under a synthetic key
        // `{taxonomy}_archive` so it can be read later — but the main
        // consumer (class-meta-templates.php) only looks at per-term. So we
        // ALSO write it as a fallback under the taxonomy itself as key
        // `{taxonomy}_0`, and leave a log note. Better than losing the data.
        $tax_templates = $overwrite ? [] : get_option('rankforge_tax_templates', []);
        if (!is_array($tax_templates)) $tax_templates = [];

        $tax_count = 0;
        foreach (get_taxonomies(['public' => true], 'names') as $tax) {
            $title_raw = $wpseo_titles['title-tax-' . $tax]    ?? '';
            $desc_raw  = $wpseo_titles['metadesc-tax-' . $tax] ?? '';
            if ($title_raw === '' && $desc_raw === '') continue;

            $key = $tax . '_archive';
            if (!$overwrite && !empty($tax_templates[$key])) continue;

            $entry = [];
            if ($title_raw !== '') $entry['title']       = $this->yoast_template_to_rankforge($title_raw);
            if ($desc_raw  !== '') $entry['description'] = $this->yoast_template_to_rankforge($desc_raw);
            $tax_templates[$key] = $entry;
            $tax_count++;
            /* translators: %s: taxonomy name */
            $log[] = sprintf(__('Taxonomy: %s', 'rankforge'), esc_html($tax));
        }
        if ($tax_count > 0) {
            update_option('rankforge_tax_templates', $tax_templates);
        }

        if ($pt_count > 0 || $tax_count > 0) {
            array_unshift($log, sprintf(
                /* translators: 1: number of post type templates, 2: number of taxonomy templates */
                __('Global settings: %1$d post type template(s), %2$d taxonomy template(s)', 'rankforge'),
                $pt_count, $tax_count
            ));
        }

        return ['log' => $log];
    }

    /**
 * Convert a Yoast template string (`%%title%% %%sep%% %%sitename%%`)
 * into RankForge's `{{var}}` syntax while preserving it as a template.
 * Unmapped Yoast variables are stripped so they don't leak into output.
 */
    private function yoast_template_to_rankforge(string $t): string {
        if ($t === '') return '';
        $map = [
            '%%title%%'             => '{{title}}',
            '%%sitename%%'          => '{{siteName}}',
            '%%sitedesc%%'          => '{{siteDesc}}',
            '%%sep%%'               => '{{separator}}',
            '%%excerpt%%'           => '{{excerpt}}',
            '%%excerpt_only%%'      => '{{excerpt}}',
            '%%author%%'            => '{{author}}',
            '%%name%%'              => '{{author}}',
            '%%category%%'          => '{{category}}',
            '%%primary_category%%'  => '{{category}}',
            '%%date%%'              => '{{date}}',
            '%%modified%%'          => '{{modifiedDate}}',
            '%%currentyear%%'       => '{{currentYear}}',
            '%%currentmonth%%'      => '{{currentMonth}}',
            '%%term_title%%'        => '{{title}}',
            '%%term_description%%'  => '{{excerpt}}',
            '%%page%%'              => '',
            '%%pagenumber%%'        => '',
            '%%pagetotal%%'         => '',
            '%%tag%%'               => '',
        ];
        $t = str_replace(array_keys($map), array_values($map), $t);
        // Strip any remaining %%unknown%% Yoast variables so they don't
        // render as literal placeholders in meta tags.
        $t = preg_replace('/%%[^%]+%%/', '', $t);
        // Collapse whitespace the stripped variables may have left behind.
        $t = trim(preg_replace('/\s{2,}/', ' ', $t));
        return $t;
    }

    /**
 * detect whether RankMath's titles option actually holds any
 * global settings worth migrating (separator, per-post-type templates,
 * per-taxonomy templates). Returns true if any of those keys carry a
 * non-empty string. Used by available_sources to flip has_globals.
 */
    private function rankmath_titles_has_globals(array $opts): bool {
        if (!empty($opts['title_separator'])) return true;
        foreach (get_post_types(['public' => true], 'names') as $pt) {
            if (!empty($opts['pt_' . $pt . '_title'])) return true;
            if (!empty($opts['pt_' . $pt . '_description'])) return true;
        }
        foreach (get_taxonomies(['public' => true], 'names') as $tax) {
            if (!empty($opts['tax_' . $tax . '_title'])) return true;
            if (!empty($opts['tax_' . $tax . '_description'])) return true;
        }
        return false;
    }

    /**
 * port RankMath global settings (separator, title/description
 * templates per post type, taxonomy archive templates) into RankForge's
 * own options. Mirrors migrate_yoast_globals — same shape, same
 * option keys on the RankForge side. RankMath templates use %variable%
 * tokens which we pass through `rankmath_template_to_rankforge`.
 */
    private function migrate_rankmath_globals(bool $overwrite): array {
        $log = [];
        $rm_titles = get_option('rank-math-options-titles');
        if (!is_array($rm_titles) || empty($rm_titles)) {
            return ['log' => $log];
        }

        // --- Separator --------------------------------------------------------
        // RankMath stores the raw character directly, not a code.
        $existing_sep = get_option('rankforge_title_separator', '');
        if (!empty($rm_titles['title_separator']) && ($overwrite || $existing_sep === '' || $existing_sep === '|')) {
            $sep = (string) $rm_titles['title_separator'];
            update_option('rankforge_title_separator', $sep);
            /* translators: %s: title separator character */
            $log[] = sprintf(__('Separator: %s', 'rankforge'), $sep);
        }

        // --- Post-type templates ---------------------------------------------
        $pt_templates = $overwrite ? [] : get_option('rankforge_title_templates', []);
        if (!is_array($pt_templates)) $pt_templates = [];

        $pt_count = 0;
        foreach (get_post_types(['public' => true], 'names') as $pt) {
            $title_raw = (string) ($rm_titles['pt_' . $pt . '_title']       ?? '');
            $desc_raw  = (string) ($rm_titles['pt_' . $pt . '_description'] ?? '');
            if ($title_raw === '' && $desc_raw === '') continue;

            if (!$overwrite && !empty($pt_templates[$pt]['title']) && !empty($pt_templates[$pt]['description'])) {
                continue;
            }

            $title = $this->rankmath_template_to_rankforge($title_raw);
            $desc  = $this->rankmath_template_to_rankforge($desc_raw);

            if ($title !== '' && ($overwrite || empty($pt_templates[$pt]['title']))) {
                $pt_templates[$pt]['title'] = $title;
            }
            if ($desc !== '' && ($overwrite || empty($pt_templates[$pt]['description']))) {
                $pt_templates[$pt]['description'] = $desc;
            }
            $pt_count++;
            /* translators: %s: post type name */
            $log[] = sprintf(__('Post type: %s', 'rankforge'), esc_html($pt));
        }
        if ($pt_count > 0) {
            update_option('rankforge_title_templates', $pt_templates);
        }

        // --- Taxonomy templates ----------------------------------------------
        $tax_templates = $overwrite ? [] : get_option('rankforge_tax_templates', []);
        if (!is_array($tax_templates)) $tax_templates = [];

        $tax_count = 0;
        foreach (get_taxonomies(['public' => true], 'names') as $tax) {
            $title_raw = (string) ($rm_titles['tax_' . $tax . '_title']       ?? '');
            $desc_raw  = (string) ($rm_titles['tax_' . $tax . '_description'] ?? '');
            if ($title_raw === '' && $desc_raw === '') continue;

            $key = $tax . '_archive';
            if (!$overwrite && !empty($tax_templates[$key])) continue;

            $entry = [];
            if ($title_raw !== '') $entry['title']       = $this->rankmath_template_to_rankforge($title_raw);
            if ($desc_raw  !== '') $entry['description'] = $this->rankmath_template_to_rankforge($desc_raw);
            $tax_templates[$key] = $entry;
            $tax_count++;
            /* translators: %s: taxonomy name */
            $log[] = sprintf(__('Taxonomy: %s', 'rankforge'), esc_html($tax));
        }
        if ($tax_count > 0) {
            update_option('rankforge_tax_templates', $tax_templates);
        }

        if ($pt_count > 0 || $tax_count > 0 || !empty($log)) {
            array_unshift($log, sprintf(
                /* translators: 1: number of post types, 2: number of taxonomies */
                __('Global settings: %1$d post types, %2$d taxonomies', 'rankforge'),
                $pt_count, $tax_count
            ));
        }
        return ['log' => $log];
    }

    /**
 * Convert a RankMath template (with %title%, %sitename%, %sep% etc.
 * tokens) into the RankForge {{title}}/{{sep}} token vocabulary.
 */
    private function rankmath_template_to_rankforge(string $tpl): string {
        if ($tpl === '') return '';
        $map = [
            '%title%'                  => '{{title}}',
            '%sitename%'               => '{{siteName}}',
            '%sitedesc%'               => '{{siteDescription}}',
            '%excerpt%'                => '{{excerpt}}',
            '%excerpt_only%'           => '{{excerpt}}',
            '%seo_title%'              => '{{title}}',
            '%seo_description%'        => '{{excerpt}}',
            '%sep%'                    => '{{separator}}',
            '%page%'                   => '',
            '%pagetotal%'              => '',
            '%pagenumber%'             => '',
            '%term%'                   => '{{termName}}',
            '%term_description%'      => '{{termDescription}}',
            '%category%'               => '{{category}}',
            '%tag%'                    => '{{tags}}',
            '%date%'                   => '{{date}}',
            '%currentdate%'            => '{{currentDate}}',
            '%currentmonth%'           => '{{currentMonth}}',
            '%currentyear%'            => '{{currentYear}}',
            '%author%'                 => '{{author}}',
            '%primary_category%'       => '{{category}}',
            '%primary_taxonomy_terms%' => '{{category}}',
            '%post_author%'            => '{{author}}',
            '%search_query%'           => '{{searchPhrase}}',
        ];
        return trim(strtr($tpl, $map));
    }

    private function migrate_rankmath(bool $overwrite): array {
        global $wpdb;
        $migrated = $skipped = $errors = 0;
        $log = [];

        // migrate RankMath globals first — separator, per-post-type
        // title/description templates, per-taxonomy archive templates. Same
        // order as migrate_yoast so the UX parity is preserved.
        $globals = $this->migrate_rankmath_globals($overwrite);
        if (!empty($globals['log'])) {
            $log = array_merge($log, $globals['log']);
        }

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        $posts = $wpdb->get_results(
            "SELECT DISTINCT pm.post_id, p.post_title
             FROM {$wpdb->postmeta} pm
             JOIN {$wpdb->posts} p ON p.ID = pm.post_id
             WHERE pm.meta_key IN (
                 'rank_math_title',
                 'rank_math_description',
                 'rank_math_focus_keyword',
                 'rank_math_robots',
                 'rank_math_canonical_url',
                 'rank_math_facebook_title',
                 'rank_math_facebook_description',
                 'rank_math_facebook_image',
                 'rank_math_twitter_title',
                 'rank_math_twitter_description'
             )
             AND pm.meta_value IS NOT NULL
             AND pm.meta_value != ''
             AND p.post_status != 'trash'
             ORDER BY pm.post_id ASC"
        );

        foreach ($posts as $row) {
            $pid = (int) $row->post_id;

            if (!$overwrite && get_post_meta($pid, '_rankforge_meta_title', true)) {
                $skipped++;
                continue;
            }

            $title = get_post_meta($pid, 'rank_math_title', true);
            $desc  = get_post_meta($pid, 'rank_math_description', true);
            $kw    = get_post_meta($pid, 'rank_math_focus_keyword', true);

            $title = $this->resolve_seo_vars($title, $pid, 'rankmath');
            $desc  = $this->resolve_seo_vars($desc, $pid, 'rankmath');

            // Rank Math may store multiple keywords comma-separated — take first
            if ($kw && strpos($kw, ',') !== false) {
                $kw = trim(explode(',', $kw)[0]);
            }

            if ($title) update_post_meta($pid, '_rankforge_meta_title', sanitize_text_field($title));
            if ($desc)  update_post_meta($pid, '_rankforge_meta_description', sanitize_text_field($desc));
            if ($kw)    update_post_meta($pid, '_rankforge_focus_keyword', sanitize_text_field($kw));
            $robots = get_post_meta($pid, 'rank_math_robots', true);
            $this->migrate_supported_post_fields($pid, [
                'noindex' => $this->robots_contains($robots, 'noindex'),
                'nofollow' => $this->robots_contains($robots, 'nofollow'),
                'canonical_url' => get_post_meta($pid, 'rank_math_canonical_url', true),
                'og_title' => get_post_meta($pid, 'rank_math_facebook_title', true),
                'og_description' => get_post_meta($pid, 'rank_math_facebook_description', true),
                'og_image' => get_post_meta($pid, 'rank_math_facebook_image', true) ?: get_post_meta($pid, 'rank_math_twitter_image', true),
                'twitter_title' => get_post_meta($pid, 'rank_math_twitter_title', true),
                'twitter_description' => get_post_meta($pid, 'rank_math_twitter_description', true),
            ]);

            $migrated++;
            $log[] = sprintf('#%d %s', $pid, esc_html(mb_strimwidth($row->post_title, 0, 60, '…')));
        }

        return compact('migrated', 'skipped', 'errors', 'log');
    }

    private function migrate_aioseo(bool $overwrite): array {
        global $wpdb;
        $migrated = $skipped = $errors = 0;
        $log = [];

        $aioseo_table = $wpdb->prefix . 'aioseo_posts';
        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        $use_table = ($wpdb->get_var("SHOW TABLES LIKE '{$aioseo_table}'") === $aioseo_table);

        if ($use_table) {
            // v4
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
            $rows = $wpdb->get_results(
                "SELECT a.*, p.post_title
                 FROM {$aioseo_table} a
                 JOIN {$wpdb->posts} p ON p.ID = a.post_id
                 WHERE {$this->aioseo_v4_supported_payload_where('a')}
                 AND p.post_status != 'trash'
                 ORDER BY a.post_id ASC"
            );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter

            foreach ($rows as $row) {
                $pid = (int) $row->post_id;

                if (!$overwrite && get_post_meta($pid, '_rankforge_meta_title', true)) {
                    $skipped++;
                    continue;
                }

                $title = $row->title ? $this->resolve_seo_vars($row->title, $pid, 'aioseo') : '';
                $desc  = $row->description ? $this->resolve_seo_vars($row->description, $pid, 'aioseo') : '';
                $kw    = '';

                if ($row->keyphrases) {
                    $kp = json_decode($row->keyphrases, true);
                    if (!empty($kp['focus']['keyphrase'])) {
                        $kw = sanitize_text_field($kp['focus']['keyphrase']);
                    }
                }

                if ($title) update_post_meta($pid, '_rankforge_meta_title', sanitize_text_field($title));
                if ($desc)  update_post_meta($pid, '_rankforge_meta_description', sanitize_text_field($desc));
                if ($kw)    update_post_meta($pid, '_rankforge_focus_keyword', $kw);
                $this->migrate_supported_post_fields($pid, [
                    'noindex' => !empty($row->robots_noindex),
                    'nofollow' => !empty($row->robots_nofollow),
                    'canonical_url' => $row->canonical_url ?? '',
                    'og_title' => $row->og_title ?? '',
                    'og_description' => $row->og_description ?? '',
                    'og_image' => ($row->og_image_custom_url ?? '') ?: ($row->og_image_url ?? '') ?: ($row->twitter_image_custom_url ?? '') ?: ($row->twitter_image_url ?? ''),
                    'twitter_title' => $row->twitter_title ?? '',
                    'twitter_description' => $row->twitter_description ?? '',
                ]);

                $migrated++;
                $log[] = sprintf('#%d %s', $pid, esc_html(mb_strimwidth($row->post_title, 0, 60, '…')));
            }
        } else {
            // v3 — postmeta
            // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
            $posts = $wpdb->get_results(
                "SELECT DISTINCT pm.post_id, p.post_title
                 FROM {$wpdb->postmeta} pm
                 JOIN {$wpdb->posts} p ON p.ID = pm.post_id
                 WHERE pm.meta_key IN ('_aioseop_title','_aioseop_description','_aioseop_keywords')
                 AND p.post_status != 'trash'
                 ORDER BY pm.post_id ASC"
            );

            foreach ($posts as $row) {
                $pid = (int) $row->post_id;

                if (!$overwrite && get_post_meta($pid, '_rankforge_meta_title', true)) {
                    $skipped++;
                    continue;
                }

                $title = get_post_meta($pid, '_aioseop_title', true);
                $desc  = get_post_meta($pid, '_aioseop_description', true);
                $kw    = get_post_meta($pid, '_aioseop_keywords', true);

                // v3 keywords may be comma-separated — take first
                if ($kw && strpos($kw, ',') !== false) {
                    $kw = trim(explode(',', $kw)[0]);
                }

                if ($title) update_post_meta($pid, '_rankforge_meta_title', sanitize_text_field($title));
                if ($desc)  update_post_meta($pid, '_rankforge_meta_description', sanitize_text_field($desc));
                if ($kw)    update_post_meta($pid, '_rankforge_focus_keyword', sanitize_text_field($kw));

                $migrated++;
                $log[] = sprintf('#%d %s', $pid, esc_html(mb_strimwidth($row->post_title, 0, 60, '…')));
            }
        }

        return compact('migrated', 'skipped', 'errors', 'log');
    }

    private function migrate_seopress(bool $overwrite): array {
        global $wpdb;
        $migrated = $skipped = $errors = 0;
        $log = [];

        // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
        $posts = $wpdb->get_results(
            "SELECT DISTINCT pm.post_id, p.post_title
             FROM {$wpdb->postmeta} pm
             JOIN {$wpdb->posts} p ON p.ID = pm.post_id
             WHERE pm.meta_key IN (
                 '_seopress_titles_title',
                 '_seopress_titles_desc',
                 '_seopress_analysis_target_kw',
                 '_seopress_robots_index',
                 '_seopress_robots_follow',
                 '_seopress_robots_canonical',
                 '_seopress_social_fb_title',
                 '_seopress_social_fb_desc',
                 '_seopress_social_fb_img',
                 '_seopress_social_twitter_title',
                 '_seopress_social_twitter_desc',
                 '_seopress_redirections_value',
                 '_seopress_redirections_type'
             )
             AND pm.meta_value IS NOT NULL
             AND pm.meta_value != ''
             AND p.post_status != 'trash'
             ORDER BY pm.post_id ASC"
        );

        foreach ($posts as $row) {
            $pid = (int) $row->post_id;

            if (!$overwrite && get_post_meta($pid, '_rankforge_meta_title', true)) {
                $skipped++;
                continue;
            }

            $title    = get_post_meta($pid, '_seopress_titles_title', true);
            $desc     = get_post_meta($pid, '_seopress_titles_desc', true);
            $kw       = get_post_meta($pid, '_seopress_analysis_target_kw', true);
            $redir_to = get_post_meta($pid, '_seopress_redirections_value', true);
            $redir_type = (int) get_post_meta($pid, '_seopress_redirections_type', true);

            $title = $this->resolve_seo_vars($title, $pid, 'seopress');
            $desc  = $this->resolve_seo_vars($desc, $pid, 'seopress');

            if ($title) update_post_meta($pid, '_rankforge_meta_title', sanitize_text_field($title));
            if ($desc)  update_post_meta($pid, '_rankforge_meta_description', sanitize_text_field($desc));
            if ($kw)    update_post_meta($pid, '_rankforge_focus_keyword', sanitize_text_field($kw));
            $this->migrate_supported_post_fields($pid, [
                'noindex' => $this->is_truthy_flag(get_post_meta($pid, '_seopress_robots_index', true)),
                'nofollow' => $this->is_truthy_flag(get_post_meta($pid, '_seopress_robots_follow', true)),
                'canonical_url' => get_post_meta($pid, '_seopress_robots_canonical', true),
                'og_title' => get_post_meta($pid, '_seopress_social_fb_title', true),
                'og_description' => get_post_meta($pid, '_seopress_social_fb_desc', true),
                'og_image' => get_post_meta($pid, '_seopress_social_fb_img', true) ?: get_post_meta($pid, '_seopress_social_twitter_img', true),
                'twitter_title' => get_post_meta($pid, '_seopress_social_twitter_title', true),
                'twitter_description' => get_post_meta($pid, '_seopress_social_twitter_desc', true),
            ]);

            // Import redirects into RankForge redirects table
            if ($redir_to) {
                $redir_type = in_array($redir_type, [301, 302, 307, 410]) ? $redir_type : 301;
                $source_path = wp_parse_url(get_permalink($pid), PHP_URL_PATH);
                if ($source_path) {
                    $table = $wpdb->prefix . 'rankforge_redirects';
                    if ($wpdb->get_var("SHOW TABLES LIKE '{$table}'") === $table) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
                        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time/admin read
                        $exists = $wpdb->get_var($wpdb->prepare(
                            "SELECT id FROM {$table} WHERE source_url = %s",
                            $source_path
                        ));
                        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
                        if (!$exists) {
                            $wpdb->insert($table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- custom plugin table; direct query is intended.
                                'source_url'   => $source_path,
                                'target_url'   => esc_url_raw($redir_to),
                                'redirect_type' => $redir_type,
                                'hits'         => 0,
                                'auto_created' => 0,
                                'created_at'   => current_time('mysql'),
                            ]);
                        }
                    }
                }
            }

            $migrated++;
            $log[] = sprintf('#%d %s', $pid, esc_html(mb_strimwidth($row->post_title, 0, 60, '…')));
        }

        return compact('migrated', 'skipped', 'errors', 'log');
    }


    // Helpers


    /**
 * Resolve common SEO title variables from Yoast / RankMath / AIOSEO / SEOPress.
 * Yoast uses %%var%%, RankMath uses %var%, AIOSEO uses #var#
 */
    private function resolve_seo_vars(string $title, int $post_id, string $plugin): string {
        if (empty($title)) return '';
        $post      = get_post($post_id);
        $site_name = get_bloginfo('name');
        $sep       = '-';

        switch ($plugin) {
            case 'yoast':
                $map = [
                    '%%title%%'          => $post ? $post->post_title : '',
                    '%%sitename%%'       => $site_name,
                    '%%sep%%'            => $sep,
                    '%%page%%'           => '',
                    '%%excerpt%%'        => $post ? wp_trim_words($post->post_excerpt ?: $post->post_content, 20) : '',
                    '%%currentdate%%'    => gmdate('Y-m-d'),
                    '%%currentyear%%'    => gmdate('Y'),
                    '%%category%%'       => '',
                    '%%tag%%'            => '',
                ];
                $title = str_replace(array_keys($map), array_values($map), $title);
                $title = preg_replace('/%%[^%]+%%/', '', $title); // strip unknowns
                break;

            case 'rankmath':
                $map = [
                    '%title%'     => $post ? $post->post_title : '',
                    '%sitename%'  => $site_name,
                    '%sep%'       => $sep,
                    '%excerpt%'   => $post ? wp_trim_words($post->post_excerpt ?: $post->post_content, 20) : '',
                    '%date%'      => $post ? get_the_date('', $post) : '',
                    '%category%'  => '',
                    '%tag%'       => '',
                ];
                $title = str_replace(array_keys($map), array_values($map), $title);
                $title = preg_replace('/%[a-z_]+%/', '', $title);
                break;

            case 'aioseo':
                $map = [
                    '#post_title#'  => $post ? $post->post_title : '',
                    '#site_title#'  => $site_name,
                    '#separator_sa#' => $sep,
                    '#current_date#' => gmdate('Y-m-d'),
                    '#post_date#'   => $post ? get_the_date('', $post) : '',
                ];
                $title = str_replace(array_keys($map), array_values($map), $title);
                $title = preg_replace('/#[^#]+#/', '', $title);
                break;

            case 'seopress':
                $map = [
                    '%%title%%'    => $post ? $post->post_title : '',
                    '%%sep%%'      => $sep,
                    '%%sitename%%' => $site_name,
                ];
                $title = str_replace(array_keys($map), array_values($map), $title);
                $title = preg_replace('/%%[^%]+%%/', '', $title);
                break;
        }

        return trim($title);
    }

    private function aioseo_v4_supported_payload_where(string $alias): string {
        $prefix = $alias !== '' ? $alias . '.' : '';
        return "(
            ({$prefix}title IS NOT NULL AND {$prefix}title != '')
            OR ({$prefix}description IS NOT NULL AND {$prefix}description != '')
            OR ({$prefix}keyphrases IS NOT NULL AND {$prefix}keyphrases != '' AND {$prefix}keyphrases != '[]' AND {$prefix}keyphrases != '{}')
            OR {$prefix}robots_noindex = 1
            OR {$prefix}robots_nofollow = 1
            OR ({$prefix}canonical_url IS NOT NULL AND {$prefix}canonical_url != '')
            OR ({$prefix}og_title IS NOT NULL AND {$prefix}og_title != '')
            OR ({$prefix}og_description IS NOT NULL AND {$prefix}og_description != '')
            OR ({$prefix}og_image_url IS NOT NULL AND {$prefix}og_image_url != '')
            OR ({$prefix}og_image_custom_url IS NOT NULL AND {$prefix}og_image_custom_url != '')
            OR ({$prefix}twitter_title IS NOT NULL AND {$prefix}twitter_title != '')
            OR ({$prefix}twitter_description IS NOT NULL AND {$prefix}twitter_description != '')
            OR ({$prefix}twitter_image_url IS NOT NULL AND {$prefix}twitter_image_url != '')
            OR ({$prefix}twitter_image_custom_url IS NOT NULL AND {$prefix}twitter_image_custom_url != '')
        )";
    }

    private function migrate_supported_post_fields(int $post_id, array $fields): void {
        foreach (['noindex' => '_rankforge_noindex', 'nofollow' => '_rankforge_nofollow'] as $field => $meta_key) {
            if (!array_key_exists($field, $fields)) continue;
            update_post_meta($post_id, $meta_key, !empty($fields[$field]) ? '1' : '');
        }

        foreach ([
            'og_title' => '_rankforge_og_title',
            'og_description' => '_rankforge_og_description',
            'twitter_title' => '_rankforge_twitter_title',
            'twitter_description' => '_rankforge_twitter_description',
        ] as $field => $meta_key) {
            $value = isset($fields[$field]) ? (string) $fields[$field] : '';
            if ($value !== '') {
                update_post_meta($post_id, $meta_key, sanitize_text_field($value));
            }
        }

        $canonical = isset($fields['canonical_url']) ? $this->sanitize_canonical_url($fields['canonical_url']) : '';
        if ($canonical !== '') {
            update_post_meta($post_id, '_rankforge_canonical_url', $canonical);
        }

        $image = isset($fields['og_image']) ? (string) $fields['og_image'] : '';
        if ($image !== '') {
            update_post_meta($post_id, '_rankforge_og_image', esc_url_raw($image));
            delete_post_meta($post_id, '_rankforge_og_image_id');
        }
    }

    private function is_truthy_flag($value): bool {
        if (is_bool($value)) return $value;
        $value = strtolower(trim((string) $value));
        return in_array($value, ['1', 'yes', 'true', 'on'], true);
    }

    private function robots_contains($robots, string $directive): bool {
        if (is_array($robots)) {
            return in_array($directive, array_map('strval', $robots), true);
        }
        $robots = forge_safe_unserialize($robots);
        if (is_array($robots)) {
            return in_array($directive, array_map('strval', $robots), true);
        }
        return strpos(',' . strtolower((string) $robots) . ',', ',' . strtolower($directive) . ',') !== false;
    }

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

        $url = esc_url_raw($url, ['http', 'https']);
        if ($url === '') return '';

        $parts = wp_parse_url($url);
        if (empty($parts['scheme']) || empty($parts['host'])) return '';
        if (!in_array(strtolower($parts['scheme']), ['http', 'https'], true)) return '';

        return $url;
    }


    // AJAX


    public function ajax_run(): void {
        check_ajax_referer('rankforge_migration', 'nonce');
        if (!current_user_can('manage_options')) wp_die('Forbidden', 403);

        $source    = sanitize_key($_POST['source'] ?? '');
        $overwrite = !empty($_POST['overwrite']);

        if (!$source) {
            wp_send_json_error('Missing source');
        }

        $result = $this->migrate($source, $overwrite);
        wp_send_json_success($result);
    }


    // Admin page


    public function render_page(): void {
        $sources = $this->detect_sources();
        $nonce   = wp_create_nonce('rankforge_migration');
        ?>
        <div class="wrap sf-admin">
        <?php RANKFORGE_Admin_Tabs::render('rankforge-migration'); ?>
        <h1><?php esc_html_e('Migration', 'rankforge'); ?><?php Forge_Docs_Link::render('rankforge', 'first-time-setup', __('Importing from another SEO plugin', 'rankforge'), __('Yoast, RankMath, AIOSEO — what carries over and what to check.', 'rankforge')); ?></h1>
        <p style="color:#6b7280;margin-top:4px;">
            <?php esc_html_e('Import your SEO data from other plugins. Your original data is never modified or deleted.', 'rankforge'); ?>
        </p>

        <style>
        .sfm-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:20px; margin-top:24px; }
        .sfm-card { background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:24px; }
        .sfm-card h3 { margin:0 0 6px; font-size:15px; display:flex; align-items:center; gap:8px; }
        .sfm-card .sfm-count { font-size:14px; color:#6b7280; margin-bottom:16px; }
        .sfm-card .sfm-note  { font-size:14px; color:#9ca3af; margin-bottom:16px; }
        .sfm-overwrite { display:flex; align-items:center; gap:7px; font-size:14px; margin-bottom:16px; }
        .sfm-btn { background:#FF6B35; color:#fff; border:none; border-radius:6px; padding:9px 20px;
                   font-size:14px; font-weight:600; cursor:pointer; transition:opacity .15s; }
        .sfm-btn:hover { opacity:.88; }
        .sfm-btn:disabled { opacity:.5; cursor:not-allowed; }
        .sfm-result { margin-top:14px; font-size:14px; display:none; }
        .sfm-result.success { color:#059669; }
        .sfm-result.error   { color:#dc2626; }
        .sfm-log { margin-top:10px; max-height:160px; overflow-y:auto; background:#f9fafb;
                   border:1px solid #e5e7eb; border-radius:6px; padding:10px 12px; font-size:14px;
                   color:#374151; line-height:1.6; }
        .sfm-empty { background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:40px;
                     text-align:center; color:#9ca3af; font-size:14px; margin-top:24px; }
        </style>

        <?php if (empty($sources)) : ?>
            <div class="sfm-empty">
                <span class="dashicons dashicons-search" style="font-size:32px;margin-bottom:12px;display:block;"></span>
                <?php esc_html_e('No supported SEO plugins detected in this database. Supported: Yoast SEO, Rank Math, All in One SEO, SEOPress.', 'rankforge'); ?>
            </div>
        <?php else : ?>
            <div class="sfm-grid">
            <?php foreach ($sources as $key => $src) :
                $has_globals = !empty($src['has_globals']);
                $has_data    = $src['count'] > 0 || $has_globals;
            ?>
                <div class="sfm-card" id="sfm-card-<?php echo esc_attr($key); ?>">
                    <h3>
                        <span class="dashicons dashicons-migrate" style="color:#FF6B35;"></span>
                        <?php echo esc_html($src['name']); ?>
                    </h3>
                    <p class="sfm-count">
                        <?php if ($src['count'] > 0) : ?>
                            <?php
                            printf(
                                esc_html( /* translators: %s: number of posts found (bolded) (singular/plural count) */ _n('%s post found', '%s posts found', $src['count'], 'rankforge') ),
                                '<strong>' . esc_html( number_format_i18n($src['count']) ) . '</strong>'
                            ); ?>
                            <?php if ($has_globals) : ?>
                                <?php esc_html_e('+ global settings', 'rankforge'); ?>
                            <?php endif; ?>
                        <?php elseif ($has_globals) : ?>
                            <em><?php esc_html_e('Global settings detected (templates / separator)', 'rankforge'); ?></em>
                        <?php else : ?>
                            <em><?php esc_html_e('Plugin detected — no posts with SEO data yet', 'rankforge'); ?></em>
                        <?php endif; ?>
                    </p>
                    <p class="sfm-note"><?php echo esc_html($src['note']); ?></p>
                    <label class="sfm-overwrite">
                        <input type="checkbox" class="sfm-overwrite-cb" <?php disabled($has_data, false); ?>>
                        <?php esc_html_e('Overwrite existing RankForge data', 'rankforge'); ?>
                    </label>
                    <button class="sfm-btn" data-source="<?php echo esc_attr($key); ?>" <?php disabled($has_data, false); ?>>
                        <?php esc_html_e('Start Migration', 'rankforge'); ?>
                    </button>
                    <div class="sfm-result" id="sfm-result-<?php echo esc_attr($key); ?>"></div>
                </div>
            <?php endforeach; ?>
            </div>
        <?php endif; ?>

        </div>

        <script>
        (function($) {
            $('.sfm-btn').on('click', function() {
                var $btn    = $(this);
                var source  = $btn.data('source');
                var $card   = $('#sfm-card-' + source);
                var overwrite = $card.find('.sfm-overwrite-cb').is(':checked') ? 1 : 0;
                var $result = $('#sfm-result-' + source);

                $btn.prop('disabled', true).text('<?php echo esc_js(__('Migrating…', 'rankforge')); ?>');
                $result.hide().removeClass('success error');

                $.post(ajaxurl, {
                    action:    'rankforge_migration_run',
                    nonce:     '<?php echo esc_js($nonce); ?>',
                    source:    source,
                    overwrite: overwrite
                }, function(res) {
                    $btn.prop('disabled', false).text('<?php echo esc_js(__('Start Migration', 'rankforge')); ?>');

                    if (res.success) {
                        var d = res.data;
                        var html = '<strong>✓ <?php echo esc_js(__('Done', 'rankforge')); ?></strong> — '
                            + '<?php echo esc_js(__('Migrated', 'rankforge')); ?>: ' + d.migrated
                            + ', <?php echo esc_js(__('Skipped', 'rankforge')); ?>: ' + d.skipped;
                        if (d.log && d.log.length) {
                            html += '<div class="sfm-log">' + d.log.join('<br>') + '</div>';
                        }
                        $result.addClass('success').html(html).show();
                    } else {
                        $result.addClass('error').text('<?php echo esc_js(__('Error', 'rankforge')); ?>: ' + (res.data || 'unknown')).show();
                    }
                }).fail(function() {
                    $btn.prop('disabled', false).text('<?php echo esc_js(__('Start Migration', 'rankforge')); ?>');
                    $result.addClass('error').text('<?php echo esc_js(__('Request failed. Try again.', 'rankforge')); ?>').show();
                });
            });
        })(jQuery);
        </script>
        <?php
    }
}
