<?php
/**
 * LangForge Migration — import multilingual data from WPML, Polylang, and TranslatePress.
 * Works directly with the database — no competitor plugin code is loaded or required.
 *
 * WPML schema used:
 * wp_icl_translations — element_id, element_type, trid, language_code, source_language_code
 * wp_icl_languages — code, english_name, default_locale, active
 * wp_options — icl_sitepress_settings (default + active languages)
 *
 * Polylang schema used:
 * Taxonomy 'language' — term per language, slug = language code
 * Taxonomy 'post_translations' — term per translation group, description = serialized array
 * wp_options — polylang (default_lang, active_languages)
 *
 * TranslatePress schema used:
 * wp_options — trp_settings (default language, published languages, URL slugs)
 * wp_trp_dictionary_* — rendered post/content translations per target language
 * wp_trp_original_meta — original_id to post_parent_id mapping
 * wp_trp_gettext_* — gettext string translations per target language
 *
 * What is migrated:
 * - _lf_language postmeta for each translated post or migrated TranslatePress copy
 * - wp_lf_translations rows (element_id, element_type, translation_group, language_code, source_language_code)
 * - wp_lf_strings rows for WPML and TranslatePress string translations
 * - Default language setting (lf_default_language option)
 * - Active languages list (lf_active_languages option)
 *
 * What is NOT migrated:
 * - WPML/Polylang translated content (the actual translated posts already exist in wp_posts)
 * - WPML translation statuses / jobs
 */

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

// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedClassFound -- LF_ is the established plugin class prefix
class LF_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_lf_migration_run', [$this, 'ajax_run']);
    }

    public function add_menu(): void {
        add_submenu_page(
            'lf-settings',
            __('Migration', 'langforge'),
            __('Migration', 'langforge'),
            'manage_options',
            'lf-migration',
            [$this, 'render_page']
        );
        $this->register_direct_admin_route();
    }

    private function register_direct_admin_route(): void {
        global $_registered_pages;

        add_action('admin_page_lf-migration', [$this, 'render_page']);
        $_registered_pages['admin_page_lf-migration'] = true;
    }

    
    // Detection
    

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

        // WPML
        $icl_table = $wpdb->prefix . 'icl_translations';
        if ($wpdb->get_var("SHOW TABLES LIKE '{$icl_table}'") === $icl_table) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
            $count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
                "SELECT COUNT(*) FROM {$icl_table} WHERE element_type LIKE 'post_%'" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPML legacy table name from $wpdb->prefix; static query.
            );
            if ($count > 0) {
                $lang_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
                    "SELECT COUNT(DISTINCT language_code) FROM {$icl_table}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPML legacy table name from $wpdb->prefix; static query.
                );
                $sources['wpml'] = [
                    'name'  => 'WPML',
                    'count' => $count,
                    'note'  => sprintf(
                        /* translators: 1: number of WPML translation records found, 2: number of languages. */
                        __('Found %1$d translation records across %2$d languages.', 'langforge'),
                        $count, $lang_count
                    ),
                ];
            }
        }

        // Polylang
        $pll_check = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read
            "SELECT COUNT(*) FROM {$wpdb->term_taxonomy}
             WHERE taxonomy = 'language'"
        );
        if ($pll_check > 0) {
            $post_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read
                "SELECT COUNT(DISTINCT tr.object_id)
                 FROM {$wpdb->term_relationships} tr
                 JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
                 JOIN {$wpdb->posts} p ON p.ID = tr.object_id
                 WHERE tt.taxonomy = 'language'
                   AND p.post_status != 'trash'"
            );
            $lang_count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read
                "SELECT COUNT(*) FROM {$wpdb->term_taxonomy} WHERE taxonomy = 'language'"
            );
            if ($post_count > 0) {
                $sources['polylang'] = [
                    'name'  => 'Polylang',
                    'count' => $post_count,
                    'note'  => sprintf(
                        /* translators: 1: number of Polylang posts assigned to languages, 2: total number of languages. */
                        __('Found %1$d posts assigned to languages, %2$d languages total.', 'langforge'),
                        $post_count, $lang_count
                    ),
                ];
            }
        }

        // TranslatePress
        $trp_settings = $this->get_translatepress_settings();
        $trp_stats = $this->get_translatepress_stats($trp_settings);
        if ($trp_stats['records'] > 0) {
            $sources['translatepress'] = [
                'name'  => 'TranslatePress',
                'count' => $trp_stats['records'],
                'note'  => sprintf(
                    /* translators: 1: number of TranslatePress translated strings, 2: number of languages. */
                    __('Found %1$d translated strings across %2$d languages.', 'langforge'),
                    $trp_stats['records'], $trp_stats['languages']
                ),
            ];
        }

        return $sources;
    }

    
    // Migration dispatcher
    

    private function migrate(string $source): array {
        switch ($source) {
            case 'wpml':           return $this->migrate_wpml();
            case 'polylang':       return $this->migrate_polylang();
            case 'translatepress': return $this->migrate_translatepress();
            default:               return ['error' => 'Unknown source'];
        }
    }

    
    // WPML
    

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

        $icl_table = $wpdb->prefix . 'icl_translations';
        $lf_table  = $wpdb->prefix . 'lf_translations';

        // 1. Import language settings from WPML options
        $wpml_settings = get_option('icl_sitepress_settings', []);
        if (is_string($wpml_settings)) {
            $wpml_settings = forge_safe_unserialize($wpml_settings);
        }

        $lf_prev_default = get_option('lf_default_language', '');
        $wpml_default = null;
        $default_source = null;

        if (!empty($wpml_settings['default_language'])) {
            $wpml_default = sanitize_key($wpml_settings['default_language']);
            $default_source = 'icl_sitepress_settings';
        }

        // Fallback when icl_sitepress_settings['default_language'] is
        // missing or empty (seen on sites where WPML was partially wiped or
        // options got truncated during serialize/unserialize round-trips).
        // In WPML, originals have source_language_code = NULL; the most common
        // such language is the site's default. This fallback keeps the
        // migration usable without forcing the admin to re-enter the default
        // language manually.
        if (!$wpml_default) {
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPML legacy table name from $wpdb->prefix; static query.
            $derived = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
                "SELECT language_code
                 FROM {$icl_table}
                 WHERE element_type LIKE 'post_%'
                   AND (source_language_code IS NULL OR source_language_code = '')
                 GROUP BY language_code
                 ORDER BY COUNT(*) DESC
                 LIMIT 1"
            );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            if ($derived) {
                $wpml_default = sanitize_key($derived);
                $default_source = 'derived from source_language_code=NULL rows';
            }
        }

        if ($wpml_default) {
            update_option('lf_default_language', $wpml_default);
            $log[] = sprintf(
                'Default language: "%s" → "%s" (source: %s)',
                $lf_prev_default ?: '(unset)',
                $wpml_default,
                $default_source
            );
        } else {
            $log[] = sprintf(
                'WARNING: Could not detect WPML default language — keeping "%s". '
                . 'If that is wrong, set it manually in Lang Forge → Settings → Default language.',
                $lf_prev_default ?: '(unset)'
            );
        }

        // follow-up #2 (Sergei 2026-04-23): carry over WPML's URL
        // format + "hide default language" flag. WPML keeps these under:
        // urls.directory_for_default_language (0 = hide /en/ on EN default)
        // language_negotiation_type (1=directory, 2=subdomain, 3=parameter)
        // urls.show_on_root (pre-WPML 4.x, alt key)
        // If we skip them, admin thinks they enabled directory-format-hide-
        // default in WPML but Lang Forge still emits /en/ on the default
        // language, breaking every pre-existing link.
        $wpml_neg_type = isset($wpml_settings['language_negotiation_type'])
            ? (int) $wpml_settings['language_negotiation_type']
            : null;
        $wpml_url_format = null;
        if ($wpml_neg_type === 1)      $wpml_url_format = 'directory';
        elseif ($wpml_neg_type === 2)  $wpml_url_format = 'subdomain';
        elseif ($wpml_neg_type === 3)  $wpml_url_format = 'parameter';
        if ($wpml_url_format) {
            update_option('lf_url_format', $wpml_url_format);
            $log[] = sprintf('URL format: imported "%s" from WPML language_negotiation_type=%d', $wpml_url_format, $wpml_neg_type);
        }

        $wpml_urls = isset($wpml_settings['urls']) && is_array($wpml_settings['urls'])
            ? $wpml_settings['urls']
            : [];
        // WPML stores the "use directory for default language" flag directly
        // (1 = prefix, 0 = hide). Lang Forge stores the inverted meaning.
        if (array_key_exists('directory_for_default_language', $wpml_urls)) {
            $wpml_dir_default = (bool) $wpml_urls['directory_for_default_language'];
            $hide_default = $wpml_dir_default ? 0 : 1;
            update_option('lf_hide_default_language', $hide_default);
            $log[] = sprintf(
                'Hide default language in URL: %s (WPML urls.directory_for_default_language=%s)',
                $hide_default ? 'enabled' : 'disabled',
                $wpml_dir_default ? '1' : '0'
            );
        } elseif (array_key_exists('show_on_root', $wpml_urls)) {
            // Legacy WPML (pre-4.x) used `show_on_root` in the opposite sense:
            // "current_language" = hide for default, "default_language" =
            // always prefix. Be permissive about unrecognised values.
            $sor = $wpml_urls['show_on_root'];
            if ($sor === 'current_language' || $sor === 'default_language') {
                $hide_default = ($sor === 'current_language') ? 1 : 0;
                update_option('lf_hide_default_language', $hide_default);
                $log[] = sprintf(
                    'Hide default language in URL: %s (WPML urls.show_on_root=%s)',
                    $hide_default ? 'enabled' : 'disabled',
                    $sor
                );
            }
        }

        // 2. Build active language list. follow-up (2026-04-23,
        // Sergei's retest): prefer the PER-SITE `active_languages` list from
        // icl_sitepress_settings over the GLOBAL `wp_icl_languages.active`
        // flag. `wp_icl_languages` is WPML's catalogue of every supported
        // locale — `active=1` there means "this language is available in
        // WPML", not "this site uses this language". Using it as the source
        // caused supplementary locales (e.g. `ar` on sites where the admin
        // had browsed-but-not-adopted Arabic) to appear in Lang Forge's
        // switcher even though the site was never multilingual in that
        // language. Per-site `active_languages` is the authoritative list.
        // Fall back to the global table only if the per-site list is empty.
        $icl_lang_table = $wpdb->prefix . 'icl_languages';
        $site_active = [];
        if (!empty($wpml_settings['active_languages']) && is_array($wpml_settings['active_languages'])) {
            foreach ($wpml_settings['active_languages'] as $code) {
                if (is_string($code)) $site_active[] = sanitize_key($code);
            }
        }

        $icl_lang_meta = [];
        if ($wpdb->get_var("SHOW TABLES LIKE '{$icl_lang_table}'") === $icl_lang_table) { // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
            $rows_meta = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
                "SELECT code, english_name, default_locale FROM {$icl_lang_table}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPML legacy table name from $wpdb->prefix; static query.
            );
            foreach ($rows_meta as $r) {
                $icl_lang_meta[sanitize_key($r->code)] = $r;
            }
        }

        if (empty($site_active)) {
            // Fallback: wpml_settings didn't carry active_languages (partial
            // reset or pre-setup state). Use global table's active=1.
            if (!empty($icl_lang_meta)) {
                $fallback = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
                    "SELECT code FROM {$icl_lang_table} WHERE active = 1" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPML legacy table name from $wpdb->prefix; static query.
                );
                foreach ($fallback as $code) $site_active[] = sanitize_key($code);
                $source_note = 'wp_icl_languages.active=1 (fallback)';
            } else {
                $source_note = 'none (no WPML tables)';
            }
        } else {
            $source_note = 'icl_sitepress_settings.active_languages (per-site)';
        }

        if (!empty($site_active)) {
            $active_langs = get_option('lf_active_languages', []);
            if (!is_array($active_langs)) $active_langs = [];

            foreach ($site_active as $code) {
                if (isset($active_langs[$code])) continue;
                $meta = $icl_lang_meta[$code] ?? null;
                $active_langs[$code] = [
                    'name'             => $meta ? sanitize_text_field($meta->english_name) : strtoupper($code),
                    'native_name'      => $meta ? sanitize_text_field($meta->english_name) : strtoupper($code),
                    'locale'           => $meta ? sanitize_text_field($meta->default_locale) : $code,
                    'show_in_switcher' => true,
                ];
            }
            update_option('lf_active_languages', $active_langs, false);
            $log[] = sprintf('Imported %d languages from WPML (source: %s)', count($site_active), $source_note);
        }

        // 3. Fetch WPML translation records for posts
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPML legacy/core table names from $wpdb->prefix; static query.
        $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
            "SELECT t.element_id, t.element_type, t.trid, t.language_code, t.source_language_code
             FROM {$icl_table} t
             JOIN {$wpdb->posts} p ON p.ID = t.element_id
             WHERE t.element_type LIKE 'post_%'
             AND p.post_status != 'trash'
             ORDER BY t.trid ASC, t.element_id ASC"
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        // 4. Map WPML trid → new LF translation_group IDs.
        
        // follow-up (2026-04-23, Sergei's retest): before building
        // the map, wipe any existing LF rows for the element_ids we're
        // about to migrate. Re-running the migration on an already-seeded
        // wp_lf_translations (a common tester flow — migrate once, tweak
        // WPML, migrate again) otherwise leaves stale rows behind:
        // the duplicate-check below is `(element_id, element_type,
        // language_code)` so a second migration keeps old rows with the
        // OLD trid-derived group_id while the fresh rows get new group
        // ids, and the same post appears under two translation_groups.
        // Clearing first makes re-migration idempotent.
        if (!empty($rows)) {
            $element_ids_csv = implode(',', array_map('intval', array_column($rows, 'element_id')));
            $wpdb->query("DELETE FROM {$lf_table} WHERE element_id IN ({$element_ids_csv})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix; IN list is integer IDs cast via intval; custom plugin table.
        }

        $trid_map = [];
        $group_id = (int) $wpdb->get_var("SELECT COALESCE(MAX(translation_group), 0) FROM {$lf_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 migration read

        foreach ($rows as $row) {
            $trid = (int)$row->trid;
            if (!isset($trid_map[$trid])) {
                $trid_map[$trid] = ++$group_id;
            }
        }

        // 5. Insert into wp_lf_translations and set _lf_language postmeta
        foreach ($rows as $row) {
            $element_id   = (int)$row->element_id;
            $element_type = sanitize_key(str_replace('post_', '', $row->element_type)); // post_page → page
            $lang_code    = sanitize_key($row->language_code);
            $source_lang  = $row->source_language_code ? sanitize_key($row->source_language_code) : null;
            $group        = $trid_map[(int)$row->trid];

            // Check for duplicate
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
            $exists = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration read
                "SELECT id FROM {$lf_table}
                 WHERE element_id = %d AND element_type = %s AND language_code = %s",
                $element_id, $element_type, $lang_code
            ));
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

            if ($exists) {
                $skipped++;
                continue;
            }

            $result = $wpdb->insert($lf_table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time migration write
                'element_id'           => $element_id,
                'element_type'         => $element_type,
                'translation_group'    => $group,
                'language_code'        => $lang_code,
                'source_language_code' => $source_lang,
            ]);

            if ($result === false) {
                $errors++;
                continue;
            }

            // Set _lf_language postmeta
            update_post_meta($element_id, '_lf_language', $lang_code);

            $migrated++;
        }

        $log[] = sprintf(
            'Posts: migrated %d, skipped %d (already existed), errors %d',
            $migrated, $skipped, $errors
        );

        // 6. Migrate WPML string translations if tables exist
        $string_log = $this->migrate_wpml_strings();
        if ($string_log) $log[] = $string_log;

        // flush rewrite rules so the language-prefixed CPT rules
        // that LangForge generated from the freshly migrated content are
        // actually stored in wp_options.rewrite_rules. Without this, the
        // tester could finish migration, toggle URL settings, and still hit
        // the old rewrite table that had no /lang/{cpt_slug}/{name}/ rules.
        flush_rewrite_rules();

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

    /**
 * Migrate WPML string translations (wp_icl_strings + wp_icl_string_translations)
 * into LangForge's wp_lf_strings table.
 */
    private function migrate_wpml_strings(): string {
        global $wpdb;

        $icl_strings      = $wpdb->prefix . 'icl_strings';
        $icl_translations = $wpdb->prefix . 'icl_string_translations';
        $lf_strings       = $wpdb->prefix . 'lf_strings';

        if ($wpdb->get_var("SHOW TABLES LIKE '{$icl_strings}'") !== $icl_strings) return ''; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
        if ($wpdb->get_var("SHOW TABLES LIKE '{$lf_strings}'") !== $lf_strings) return ''; // 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 migration read

        // B6: scoped clear of previously-imported WPML rows to avoid duplicates
        // on re-run. Previous version was `DELETE … WHERE 1=1` which nuked
        // EVERY row in wp_lf_strings — including manual translations the user
        // added between migrations. Restrict the wipe to domains that actually
        // come from WPML (each WPML "context" maps to LangForge string_domain),
        // so unrelated domains (anything the user typed in by hand under a
        // different context) survive a re-run.
        $wpml_contexts = $wpdb->get_col("SELECT DISTINCT context FROM {$icl_strings}"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
        $wpml_contexts = array_filter($wpml_contexts, function ($c) { return is_string($c) && $c !== ''; });
        if (!empty($wpml_contexts)) {
            $placeholders = implode(',', array_fill(0, count($wpml_contexts), '%s'));
            $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; values %s-prepared below
                "DELETE FROM {$lf_strings} WHERE string_domain IN ($placeholders)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- Table name from $wpdb->prefix; IN built from %s placeholders, values are %-prepared.
                ...$wpml_contexts
            ));
        }

        // 1. Import original strings (source language) — use value as string_name
        // so gettext filter can match by actual text
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPML legacy table name from $wpdb->prefix; static query.
        $originals = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
            "SELECT s.id, s.context, s.name, s.value, s.language
             FROM {$icl_strings} s
             ORDER BY s.id ASC
             LIMIT 5000"
        );
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $count = 0;
        foreach ($originals as $row) {
            if (empty($row->value)) continue;
            // Use the original text value as string_name (not the WPML hash)
            $string_name = $row->value;
            $domain = sanitize_text_field($row->context);
            $source_lang = sanitize_key($row->language ?: 'en');

            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
            $exists = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration read
                "SELECT id FROM {$lf_strings}
                 WHERE string_domain = %s AND string_name = %s AND string_language = %s",
                $domain, $string_name, $source_lang
            ));
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            if (!$exists) {
                $wpdb->insert($lf_strings, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time migration write
                    'string_domain'   => $domain,
                    'string_name'     => $string_name,
                    'string_value'    => $row->value,
                    'string_language' => $source_lang,
                    'status'          => 1,
                ]);
                $count++;
            }

            // 2. Import translations for this string
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- WPML legacy table name from $wpdb->prefix, values are %-prepared.
            $translations = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- WPML legacy table from $wpdb->prefix; one-time migration read
                "SELECT language, value FROM {$icl_translations}
                 WHERE string_id = %d AND status = 10",
                $row->id
            ));
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

            foreach ($translations as $t) {
                if (empty($t->value)) continue;
                $trans_lang = sanitize_key($t->language);

                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
                $exists = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration read
                    "SELECT id FROM {$lf_strings}
                     WHERE string_domain = %s AND string_name = %s AND string_language = %s",
                    $domain, $string_name, $trans_lang
                ));
                // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                if (!$exists) {
                    $wpdb->insert($lf_strings, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time migration write
                        'string_domain'   => $domain,
                        'string_name'     => $string_name,
                        'string_value'    => $t->value,
                        'string_language' => $trans_lang,
                        'status'          => 1,
                    ]);
                    $count++;
                }
            }
        }

        return $count > 0 ? sprintf('Imported %d string translations from WPML', $count) : '';
    }

    
    // TranslatePress
    

    private function get_translatepress_settings(): array {
        $settings = get_option('trp_settings', []);
        if (is_string($settings)) {
            $settings = forge_safe_unserialize($settings);
        }
        return is_array($settings) ? $settings : [];
    }

    private function translatepress_table_exists(string $table): bool {
        global $wpdb;
        return $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table existence check
    }

    private function translatepress_locale_to_table_part(string $locale): string {
        return strtolower($locale);
    }

    private function translatepress_locale_to_lf_code(string $locale, array $settings): string {
        $slugs = isset($settings['url-slugs']) && is_array($settings['url-slugs'])
            ? $settings['url-slugs']
            : [];

        if (!empty($slugs[$locale]) && is_string($slugs[$locale])) {
            return sanitize_key($slugs[$locale]);
        }

        if (strpos($locale, '_') !== false) {
            $parts = explode('_', $locale, 2);
            return sanitize_key($parts[0]);
        }

        return sanitize_key($locale);
    }

    private function get_translatepress_languages(array $settings): array {
        $default_locale = '';
        if (!empty($settings['default-language']) && is_string($settings['default-language'])) {
            $default_locale = sanitize_text_field($settings['default-language']);
        }

        $locales = [];
        if ($default_locale) {
            $locales[] = $default_locale;
        }

        foreach (['publish-languages', 'translation-languages'] as $key) {
            if (!empty($settings[$key]) && is_array($settings[$key])) {
                foreach ($settings[$key] as $locale) {
                    if (is_string($locale) && $locale !== '') {
                        $locales[] = sanitize_text_field($locale);
                    }
                }
            }
        }

        $locales = array_values(array_unique(array_filter($locales)));
        $available = class_exists('LF_Core') ? LF_Core::instance()->get_available_languages() : [];
        $languages = [];

        foreach ($locales as $locale) {
            $code = $this->translatepress_locale_to_lf_code($locale, $settings);
            if (!$code) continue;

            $meta = $available[$code] ?? null;
            if (!$meta) {
                foreach ($available as $candidate_code => $candidate_meta) {
                    if (!empty($candidate_meta['locale']) && strtolower((string) $candidate_meta['locale']) === strtolower($locale)) {
                        $meta = $candidate_meta;
                        $code = sanitize_key($candidate_code);
                        break;
                    }
                }
            }

            $languages[$code] = [
                'code'        => $code,
                'locale'      => $locale,
                'table_part'  => $this->translatepress_locale_to_table_part($locale),
                'name'        => $meta['name'] ?? strtoupper($code),
                'native_name' => $meta['native_name'] ?? ($meta['name'] ?? strtoupper($code)),
                'flag'        => $meta['flag'] ?? substr($code, 0, 2),
                'is_default'  => $default_locale && strtolower($locale) === strtolower($default_locale),
            ];
        }

        return $languages;
    }

    private function get_translatepress_stats(array $settings): array {
        global $wpdb;
        $records = 0;
        $languages_with_records = [];
        $languages = $this->get_translatepress_languages($settings);
        $default_locale = !empty($settings['default-language']) && is_string($settings['default-language'])
            ? sanitize_text_field($settings['default-language'])
            : '';

        if ($default_locale && count($languages) > 1) {
            $default_part = $this->translatepress_locale_to_table_part($default_locale);
            foreach ($languages as $code => $language) {
                if (!empty($language['is_default'])) continue;

                $dict_table = $wpdb->prefix . 'trp_dictionary_' . $default_part . '_' . $language['table_part'];
                if ($this->translatepress_table_exists($dict_table)) {
                    // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- TranslatePress table name from $wpdb->prefix; static query.
                    $count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- TranslatePress table from $wpdb->prefix; one-time migration read
                        "SELECT COUNT(*) FROM {$dict_table}
                         WHERE translated IS NOT NULL
                           AND translated <> ''
                           AND status <> 0"
                    );
                    // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $records += $count;
                    if ($count > 0) $languages_with_records[$code] = true;
                }

                $gettext_table = $wpdb->prefix . 'trp_gettext_' . $language['table_part'];
                if ($this->translatepress_table_exists($gettext_table)) {
                    // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- TranslatePress table name from $wpdb->prefix; static query.
                    $count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- TranslatePress table from $wpdb->prefix; one-time migration read
                        "SELECT COUNT(*) FROM {$gettext_table}
                         WHERE translated IS NOT NULL
                           AND translated <> ''
                           AND status <> 0"
                    );
                    // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    $records += $count;
                    if ($count > 0) $languages_with_records[$code] = true;
                }
            }
        }

        if ($records === 0) {
            $like = $wpdb->esc_like($wpdb->prefix . 'trp_dictionary_') . '%';
            $tables = $wpdb->get_col($wpdb->prepare('SHOW TABLES LIKE %s', $like)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- table discovery; one-time migration read
            foreach ($tables as $table) {
                if (!preg_match('/^' . preg_quote($wpdb->prefix, '/') . 'trp_dictionary_[a-z0-9_]+_[a-z0-9_]+$/', $table)) continue;
                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- TranslatePress table validated by regex above; static query.
                $count = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- TranslatePress table validated by regex above; one-time migration read
                    "SELECT COUNT(*) FROM {$table}
                     WHERE translated IS NOT NULL
                       AND translated <> ''
                       AND status <> 0"
                );
                // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $records += $count;
                if ($count > 0) $languages_with_records[$table] = true;
            }
        }

        return [
            'records'   => $records,
            'languages' => max(1, count($languages_with_records)),
        ];
    }

    private function upsert_lf_translation_row(int $post_id, string $post_type, string $language, ?string $source_language, int $group): bool {
        global $wpdb;
        $table = $wpdb->prefix . 'lf_translations';

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
        $existing = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration read
            "SELECT id FROM {$table}
             WHERE element_id = %d AND element_type = %s AND language_code = %s",
            $post_id, $post_type, $language
        ));
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $data = [
            'element_id'           => $post_id,
            'element_type'         => $post_type,
            'translation_group'    => $group,
            'language_code'        => $language,
            'source_language_code' => $source_language,
        ];

        if ($existing) {
            return $wpdb->update($table, $data, ['id' => (int) $existing]) !== false; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time migration write
        }

        return $wpdb->insert($table, $data) !== false; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time migration write
    }

    private function upsert_lf_string(string $domain, string $name, string $value, string $language): bool {
        global $wpdb;
        $table = $wpdb->prefix . 'lf_strings';

        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
        $existing = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration read
            "SELECT id FROM {$table}
             WHERE string_domain = %s AND string_name = %s AND string_language = %s",
            $domain, $name, $language
        ));
        // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

        $data = [
            'string_domain'   => $domain,
            'string_name'     => $name,
            'string_value'    => $value,
            'string_language' => $language,
            'status'          => 1,
        ];

        if ($existing) {
            return $wpdb->update($table, $data, ['id' => (int) $existing]) !== false; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time migration write
        }

        return $wpdb->insert($table, $data) !== false; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time migration write
    }

    private function apply_translatepress_replacements(string $value, array $translations): string {
        foreach ($translations as $original => $translated) {
            if ($original === '' || $translated === '') continue;
            $value = str_replace($original, $translated, $value);
        }
        return $value;
    }

    private function copy_translatepress_post_meta(int $source_id, int $target_id): void {
        $skip_prefixes = ['_lf_'];
        $skip_keys = ['_edit_lock', '_edit_last', '_wp_old_slug'];
        $meta = get_post_meta($source_id);

        foreach ($meta as $key => $values) {
            if (in_array($key, $skip_keys, true)) continue;
            foreach ($skip_prefixes as $prefix) {
                if (strpos($key, $prefix) === 0) continue 2;
            }

            delete_post_meta($target_id, $key);
            foreach ($values as $value) {
                add_post_meta($target_id, $key, forge_safe_unserialize($value));
            }
        }
    }

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

        $settings = $this->get_translatepress_settings();
        if (empty($settings['default-language']) || !is_string($settings['default-language'])) {
            return [
                'migrated' => 0,
                'skipped'  => 0,
                'errors'   => 1,
                'log'      => [__('TranslatePress settings were not found.', 'langforge')],
            ];
        }

        $default_locale = sanitize_text_field($settings['default-language']);
        $default_code = $this->translatepress_locale_to_lf_code($default_locale, $settings);
        $languages = $this->get_translatepress_languages($settings);

        if (!$default_code || empty($languages[$default_code]) || count($languages) < 2) {
            return [
                'migrated' => 0,
                'skipped'  => 0,
                'errors'   => 1,
                'log'      => [__('TranslatePress does not have enough configured languages to migrate.', 'langforge')],
            ];
        }

        update_option('lf_default_language', $default_code);
        update_option('lf_url_format', 'directory');

        if (array_key_exists('add-subdirectory-to-default-language', $settings)) {
            update_option('lf_hide_default_language', $settings['add-subdirectory-to-default-language'] === 'yes' ? 0 : 1);
        }

        $active_langs = get_option('lf_active_languages', []);
        if (!is_array($active_langs)) $active_langs = [];

        foreach ($languages as $code => $language) {
            $active_langs[$code] = [
                'name'             => sanitize_text_field($language['name']),
                'native_name'      => sanitize_text_field($language['native_name']),
                'flag'             => sanitize_key($language['flag']),
                'locale'           => sanitize_text_field($language['locale']),
                'show_in_switcher' => true,
            ];
        }
        update_option('lf_active_languages', $active_langs, false);
        $log[] = sprintf('Imported %d languages from TranslatePress', count($languages));

        $default_part = $this->translatepress_locale_to_table_part($default_locale);
        $original_meta_table = $wpdb->prefix . 'trp_original_meta';
        $lf_table = $wpdb->prefix . 'lf_translations';

        foreach ($languages as $target_code => $language) {
            if (!empty($language['is_default'])) continue;

            $dict_table = $wpdb->prefix . 'trp_dictionary_' . $default_part . '_' . $language['table_part'];
            if (!$this->translatepress_table_exists($dict_table)) {
                $skipped++;
                continue;
            }

            $rows = [];
            if ($this->translatepress_table_exists($original_meta_table)) {
                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- TranslatePress table names from $wpdb->prefix; static query.
                $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- TranslatePress table from $wpdb->prefix; one-time migration read
                    "SELECT d.original, d.translated, d.original_id, om.meta_value AS post_id
                     FROM {$dict_table} d
                     LEFT JOIN {$original_meta_table} om
                       ON om.original_id = d.original_id
                      AND om.meta_key = 'post_parent_id'
                     WHERE d.translated IS NOT NULL
                       AND d.translated <> ''
                       AND d.status <> 0
                     ORDER BY d.id ASC"
                );
                // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
            }

            $by_post = [];
            $loose_strings = [];
            foreach ($rows as $row) {
                $original = (string) $row->original;
                $translated = (string) $row->translated;
                if ($original === '' || $translated === '') continue;

                $post_id = (int) $row->post_id;
                if ($post_id > 0) {
                    if (!isset($by_post[$post_id])) $by_post[$post_id] = [];
                    $by_post[$post_id][$original] = $translated;
                } else {
                    $loose_strings[$original] = $translated;
                }
            }

            foreach ($loose_strings as $original => $translated) {
                $domain = 'TranslatePress';
                if ($this->upsert_lf_string($domain, $original, $original, $default_code)) $migrated++;
                if ($this->upsert_lf_string($domain, $original, $translated, $target_code)) $migrated++;
            }

            foreach ($by_post as $source_id => $translations) {
                $source = get_post($source_id);
                if (!$source || $source->post_status === 'trash') {
                    $skipped++;
                    continue;
                }

                $target_id = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read
                    "SELECT pm.post_id
                     FROM {$wpdb->postmeta} pm
                     INNER JOIN {$wpdb->postmeta} lm
                        ON lm.post_id = pm.post_id
                       AND lm.meta_key = '_lf_migrated_from_translatepress_language'
                       AND lm.meta_value = %s
                     INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_status != 'trash'
                     WHERE pm.meta_key = '_lf_migrated_from_translatepress'
                       AND pm.meta_value = %s
                     LIMIT 1",
                    $target_code,
                    (string) $source_id
                ));

                $post_data = [
                    'post_title'   => $this->apply_translatepress_replacements($source->post_title, $translations),
                    'post_content' => $this->apply_translatepress_replacements($source->post_content, $translations),
                    'post_excerpt' => $this->apply_translatepress_replacements($source->post_excerpt, $translations),
                ];

                if ($target_id) {
                    $post_data['ID'] = $target_id;
                    $result = wp_update_post($post_data, true);
                } else {
                    $post_data = array_merge([
                        'post_type'      => $source->post_type,
                        'post_status'    => $source->post_status,
                        'post_author'    => $source->post_author,
                        'post_parent'    => $source->post_parent,
                        'menu_order'     => $source->menu_order,
                        'comment_status' => $source->comment_status,
                        'ping_status'    => $source->ping_status,
                        'post_name'      => sanitize_title($source->post_name . '-' . $target_code),
                    ], $post_data);
                    $result = wp_insert_post($post_data, true);
                    if (!is_wp_error($result)) {
                        $target_id = (int) $result;
                        $this->copy_translatepress_post_meta((int) $source_id, $target_id);
                    }
                }

                if (is_wp_error($result)) {
                    $errors++;
                    continue;
                }

                $target_id = (int) $result;
                update_post_meta($source_id, '_lf_language', $default_code);
                update_post_meta($target_id, '_lf_language', $target_code);
                update_post_meta($target_id, '_lf_migrated_from_translatepress', (string) $source_id);
                update_post_meta($target_id, '_lf_migrated_from_translatepress_language', $target_code);

                $wpdb->query($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration write
                    "DELETE FROM {$lf_table} WHERE element_id IN (%d, %d)", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
                    $source_id,
                    $target_id
                ));
                $group = (int) $wpdb->get_var("SELECT COALESCE(MAX(translation_group), 0) FROM {$lf_table}") + 1; // 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 migration read

                $source_ok = $this->upsert_lf_translation_row((int) $source_id, $source->post_type, $default_code, null, $group);
                $target_ok = $this->upsert_lf_translation_row($target_id, $source->post_type, $target_code, $default_code, $group);

                if ($source_ok && $target_ok) {
                    $migrated++;
                } else {
                    $errors++;
                }
            }
        }

        $string_count = $this->migrate_translatepress_strings($settings, $languages, $default_code);
        if ($string_count > 0) {
            $migrated += $string_count;
            $log[] = sprintf('Imported %d string translations from TranslatePress', $string_count);
        }

        $log[] = sprintf(
            'TranslatePress: migrated %d, skipped %d, errors %d',
            $migrated, $skipped, $errors
        );

        flush_rewrite_rules();

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

    private function migrate_translatepress_strings(array $settings, array $languages, string $default_code): int {
        global $wpdb;
        $count = 0;

        foreach ($languages as $target_code => $language) {
            if (!empty($language['is_default'])) continue;

            $gettext_table = $wpdb->prefix . 'trp_gettext_' . $language['table_part'];
            if (!$this->translatepress_table_exists($gettext_table)) continue;

            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- TranslatePress table name from $wpdb->prefix; static query.
            $rows = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- TranslatePress table from $wpdb->prefix; one-time migration read
                "SELECT original, translated, domain
                 FROM {$gettext_table}
                 WHERE translated IS NOT NULL
                   AND translated <> ''
                   AND status <> 0
                 ORDER BY id ASC
                 LIMIT 5000"
            );
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

            foreach ($rows as $row) {
                $original = (string) $row->original;
                $translated = (string) $row->translated;
                if ($original === '' || $translated === '') continue;

                $domain = sanitize_text_field($row->domain ?: 'TranslatePress');
                if ($this->upsert_lf_string($domain, $original, $original, $default_code)) $count++;
                if ($this->upsert_lf_string($domain, $original, $translated, $target_code)) $count++;
            }
        }

        return $count;
    }

    
    // Polylang
    

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

        $lf_table = $wpdb->prefix . 'lf_translations';

        // 1. Import language settings
        $pll_options = get_option('polylang', []);
        if (is_string($pll_options)) $pll_options = forge_safe_unserialize($pll_options);

        // 2. Get all Polylang language terms
        $lang_terms = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read
            "SELECT t.term_id, t.slug, t.name, tt.description
             FROM {$wpdb->terms} t
             JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id
             WHERE tt.taxonomy = 'language'"
        );

        // Build slug → [name, locale] map
        $lang_info = [];
        foreach ($lang_terms as $term) {
            $meta = forge_safe_unserialize($term->description);
            if (!is_array($meta)) {
                $meta = get_term_meta($term->term_id, '_pll_language', true);
                if (is_string($meta)) $meta = forge_safe_unserialize($meta);
            }

            $lang_info[$term->slug] = [
                'name'   => $term->name,
                'locale' => is_array($meta) ? ($meta['locale'] ?? $term->slug) : $term->slug,
                'flag'   => is_array($meta) ? ($meta['flag_code'] ?? ($meta['flag'] ?? $term->slug)) : $term->slug,
            ];
        }

        if (!empty($pll_options['default_lang'])) {
            update_option('lf_default_language', sanitize_key($pll_options['default_lang']));
        } elseif (!empty($lang_info)) {
            update_option('lf_default_language', sanitize_key((string) array_key_first($lang_info)));
        }

        // 3. Import the source plugin language set exactly. Keeping stale
        // LangForge defaults such as ru/en here makes the switcher show
        // languages that Polylang never had on this site.
        $active_langs = [];
        foreach ($lang_info as $code => $info) {
            $active_langs[$code] = [
                'name'             => sanitize_text_field($info['name']),
                'native_name'      => sanitize_text_field($info['name']),
                'locale'           => sanitize_text_field($info['locale']),
                'flag'             => sanitize_text_field($info['flag']),
                'show_in_switcher' => true,
            ];
        }
        update_option('lf_active_languages', $active_langs, false);
        $log[] = sprintf('Imported %d languages from Polylang', count($lang_info));

        // 4. Read translation groups from 'post_translations' taxonomy
        // Each term's description is a serialized array: ['en' => 100, 'fr' => 200]
        $group_terms = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read
            "SELECT t.term_id, tt.description
             FROM {$wpdb->terms} t
             JOIN {$wpdb->term_taxonomy} tt ON tt.term_id = t.term_id
             WHERE tt.taxonomy = 'post_translations'"
        );

        $translation_groups = [];
        $group_post_ids = [];
        foreach ($group_terms as $group_term) {
            $translations = forge_safe_unserialize($group_term->description);
            if (!is_array($translations) || count($translations) < 1) continue;

            $translation_groups[] = $translations;
            foreach ($translations as $post_id) {
                $post_id = (int) $post_id;
                if ($post_id > 0) $group_post_ids[] = $post_id;
            }
        }

        $language_post_ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read
            "SELECT DISTINCT tr.object_id
             FROM {$wpdb->term_relationships} tr
             JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
             JOIN {$wpdb->posts} p ON p.ID = tr.object_id
             WHERE tt.taxonomy = 'language'
               AND p.post_status != 'trash'"
        );
        $all_polylang_post_ids = array_unique(array_filter(array_map('intval', array_merge($group_post_ids, $language_post_ids))));
        if (!empty($all_polylang_post_ids)) {
            $post_ids_csv = implode(',', $all_polylang_post_ids);
            $wpdb->query("DELETE FROM {$lf_table} WHERE element_id IN ({$post_ids_csv})"); // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Table name from $wpdb->prefix; IN list is integer IDs cast via intval; custom plugin table.
        }

        // 5. Find default language (source)
        $default_lang = get_option('lf_default_language', 'en');

        // 6. Get current max translation_group
        $group_id = (int) $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration read
            "SELECT COALESCE(MAX(translation_group), 0) FROM {$lf_table}" // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix; static query.
        );

        foreach ($translation_groups as $translations) {
            $group_id++;

            foreach ($translations as $lang_code => $post_id) {
                $lang_code = sanitize_key($lang_code);
                $post_id   = (int)$post_id;
                if (!$post_id || !$lang_code) continue;

                // Verify post exists
                $post_type = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read
                    "SELECT post_type FROM {$wpdb->posts}
                     WHERE ID = %d AND post_status != 'trash'",
                    $post_id
                ));
                if (!$post_type) continue;

                // Duplicate check
                // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
                $exists = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration read
                    "SELECT id FROM {$lf_table}
                     WHERE element_id = %d AND element_type = %s AND language_code = %s",
                    $post_id, $post_type, $lang_code
                ));
                // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

                if ($exists) {
                    $skipped++;
                    continue;
                }

                $source_lang = ($lang_code === $default_lang) ? null : $default_lang;

                $result = $wpdb->insert($lf_table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time migration write
                    'element_id'           => $post_id,
                    'element_type'         => $post_type,
                    'translation_group'    => $group_id,
                    'language_code'        => $lang_code,
                    'source_language_code' => $source_lang,
                ]);

                if ($result === false) {
                    $errors++;
                    continue;
                }

                update_post_meta($post_id, '_lf_language', $lang_code);
                $migrated++;
            }
        }

        // 7. Handle posts assigned to a language but not in any group
        // (orphaned single-language posts — no translation counterpart)
        $orphaned_posts = $wpdb->get_results( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- core table; one-time migration read
            "SELECT tr.object_id AS post_id, t.slug AS lang_code, p.post_type
             FROM {$wpdb->term_relationships} tr
             JOIN {$wpdb->term_taxonomy} tt ON tt.term_taxonomy_id = tr.term_taxonomy_id
             JOIN {$wpdb->terms} t ON t.term_id = tt.term_id
             JOIN {$wpdb->posts} p ON p.ID = tr.object_id
             WHERE tt.taxonomy = 'language'
             AND p.post_status != 'trash'
             ORDER BY tr.object_id ASC"
        );

        foreach ($orphaned_posts as $row) {
            $pid  = (int)$row->post_id;
            $lang = sanitize_key($row->lang_code);
            $type = sanitize_key($row->post_type);

            update_post_meta($pid, '_lf_language', $lang);

            // Check if already in lf_translations
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- Table name from $wpdb->prefix, values are %-prepared.
            $exists = $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- own table from $wpdb->prefix; one-time migration read
                "SELECT id FROM {$lf_table}
                 WHERE element_id = %d AND element_type = %s AND language_code = %s",
                $pid, $type, $lang
            ));
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

            if (!$exists) {
                $group_id++;
                $wpdb->insert($lf_table, [ // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- own table from $wpdb->prefix; one-time migration write
                    'element_id'           => $pid,
                    'element_type'         => $type,
                    'translation_group'    => $group_id,
                    'language_code'        => $lang,
                    'source_language_code' => null,
                ]);
                $migrated++;
            }
        }

        $log[] = sprintf(
            'Posts: migrated %d, skipped %d (already existed), errors %d',
            $migrated, $skipped, $errors
        );

        // same flush as WPML — new CPT rules need to land in wp_options.
        flush_rewrite_rules();

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

    
    // AJAX
    

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

        $source = sanitize_key(wp_unslash($_POST['source'] ?? ''));
        if (!$source) wp_send_json_error('Missing source');

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

    
    // Admin page
    

    public function render_page(): void {
        $sources = $this->detect_sources();
        $nonce   = wp_create_nonce('lf_migration');
        ?>
        <div class="wrap lf-admin">
        <?php
        // v2 (2026-05-05): Migration is now a tab inside the
        // Tools hub. Render the hub tab strip so the user sees that
        // they're in Tools and can switch to Glossary / TM / Import-Export.
        if (class_exists('LF_Admin_Tabs')) { LF_Admin_Tabs::render(); }
        ?>
        <h1><?php esc_html_e('Migration', 'langforge'); ?></h1>
        <p style="color:#6b7280;margin-top:4px;">
            <?php esc_html_e('Import your multilingual data from other plugins. Original data is never modified or deleted.', 'langforge'); ?>
        </p>
        <p style="background:#FEF9C3;border:1px solid #FDE68A;padding:10px 14px;border-radius:6px;font-size:14px;margin-top:12px;color:#78350F;">
            <strong><?php esc_html_e('Important:', 'langforge'); ?></strong>
            <?php esc_html_e('Run migration on a staging site first. WPML and Polylang migrations import existing translated posts; TranslatePress migration creates translated post copies from its dictionary tables.', 'langforge'); ?>
        </p>

        <style>
        .lfm-grid  { display:grid; grid-template-columns:repeat(auto-fill,minmax(340px,1fr)); gap:20px; margin-top:24px; }
        .lfm-card  { background:#fff; border:1px solid #e5e7eb; border-radius:10px; padding:24px; }
        .lfm-card h3 { margin:0 0 6px; font-size:15px; display:flex; align-items:center; gap:8px; }
        .lfm-count { font-size:14px; color:#6b7280; margin-bottom:4px; }
        .lfm-note  { font-size:14px; color:#9ca3af; margin-bottom:16px; }
        .lfm-btn   { background:#0EC49E; color:#fff; border:none; border-radius:6px; padding:9px 20px;
                     font-size:14px; font-weight:600; cursor:pointer; transition:opacity .15s; }
        .lfm-btn:hover    { opacity:.88; }
        .lfm-btn:disabled { opacity:.5; cursor:not-allowed; }
        .lfm-result { margin-top:14px; font-size:14px; display:none; }
        .lfm-result.success { color:#059669; }
        .lfm-result.error   { color:#dc2626; }
        .lfm-log { margin-top:10px; max-height:200px; overflow-y:auto; background:#f9fafb;
                   border:1px solid #e5e7eb; border-radius:6px; padding:10px 12px; font-size:14px;
                   color:#374151; line-height:1.7; }
        .lfm-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="lfm-empty">
                <span class="dashicons dashicons-search" style="font-size:32px;margin-bottom:12px;display:block;"></span>
                <?php esc_html_e('No supported multilingual plugins detected. Supported: WPML, Polylang, TranslatePress.', 'langforge'); ?>
            </div>
        <?php else : ?>
            <div class="lfm-grid">
            <?php foreach ($sources as $key => $src) : ?>
                <div class="lfm-card" id="lfm-card-<?php echo esc_attr($key); ?>">
                    <h3>
                        <span class="dashicons dashicons-migrate" style="color:#0EC49E;"></span>
                        <?php echo esc_html($src['name']); ?>
                    </h3>
                    <p class="lfm-count">
                        <strong><?php echo esc_html(number_format_i18n($src['count'])); ?></strong>
                        <?php esc_html_e('translation records', 'langforge'); ?>
                    </p>
                    <p class="lfm-note"><?php echo esc_html($src['note']); ?></p>
                    <button class="lfm-btn" data-source="<?php echo esc_attr($key); ?>">
                        <?php esc_html_e('Start Migration', 'langforge'); ?>
                    </button>
                    <div class="lfm-result" id="lfm-result-<?php echo esc_attr($key); ?>"></div>
                </div>
            <?php endforeach; ?>
            </div>
        <?php endif; ?>

        </div>

        <script>
        (function($) {
            $('.lfm-btn').on('click', function() {
                var $btn    = $(this);
                var source  = $btn.data('source');
                var $result = $('#lfm-result-' + source);

                if (!confirm('<?php echo esc_js(__('This will import language assignments and translation groups into LangForge. Continue?', 'langforge')); ?>')) {
                    return;
                }

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

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

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