# metasync/2.7.2/includes/class-metasync-external-importer.php

Search Atlas SEO – OTTO AI SEO Automation for WordPress, version 2.7.2. 4,745 lines.

- Page: https://pluginprobe.com/plugins/metasync/2.7.2/code/includes/class-metasync-external-importer.php
- Raw: https://pluginprobe.com/plugins/metasync/2.7.2/raw/includes/class-metasync-external-importer.php
- Modified: 2026-10-07T12:05:24+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/metasync/2.7.2/code/includes/class-metasync-external-importer.php#L10-L20`.

```php
<?php

/**
 * Import data from other SEO plugins.
 *
 * @link       https://searchatlas.com
 * @since      1.0.0
 * @package    Metasync
 * @subpackage Metasync/includes
 * @author     Engineering Team <support@searchatlas.com>
 */

// Abort if this file is accessed directly.
if (!defined('ABSPATH')) {
    exit;
}

class Metasync_External_Importer
{
    private $db_redirection;
    private $redirection_importer;

    public function __construct($db_redirection = null)
    {
        $this->db_redirection = $db_redirection;
        
        // Initialize redirection importer if DB resource is provided
        if ($this->db_redirection) {
            require_once plugin_dir_path(dirname(__FILE__)) . 'redirections/class-metasync-redirection-importer.php';
            $this->redirection_importer = new Metasync_Redirection_Importer($this->db_redirection);
        }
    }

    /**
     * Get available plugins for a specific import type
     */
    public function get_plugins_for_type($type)
    {
        $plugins = [
            'yoast' => ['name' => 'Yoast SEO', 'constant' => 'WPSEO_VERSION'],
            'rankmath' => ['name' => 'Rank Math', 'constant' => 'RANK_MATH_VERSION'],
            'aioseo' => ['name' => 'All in One SEO', 'constant' => 'AIOSEO_VERSION'],
            'redirection' => ['name' => 'Redirection', 'constant' => 'REDIRECTION_VERSION'],
            'simple301' => ['name' => 'Simple 301 Redirects', 'constant' => 'SIMPLE_301_REDIRECTS_VERSION'],
        ];

        // Filter plugins based on type support
        $supported_plugins = [];
        switch ($type) {
            case 'redirections':
                if ($this->redirection_importer) {
                    return $this->redirection_importer->get_available_plugins();
                }
                return [];
            case 'sitemap':
            case 'robots':
                // These types are generally supported by the main SEO plugins
                $supported = ['yoast', 'rankmath', 'aioseo'];
                foreach ($supported as $slug) {
                    $plugin = $plugins[$slug];
                    $is_active = defined($plugin['constant']);

                    // Basic data structure similar to redirection importer
                    $supported_plugins[$slug] = [
                        'name' => $plugin['name'],
                        'key' => $slug,
                        'installed' => $is_active,
                        'has_data' => $is_active, // Assume data exists if active for now
                        'count' => 0, // Count not applicable/calculated yet
                        'version' => $is_active ? constant($plugin['constant']) : ''
                    ];
                }
                break;

            case 'schema':
                // Check for actual per-post schema data
                // Even if plugin is deactivated, we can still import the data
                $supported = ['yoast', 'rankmath', 'aioseo'];
                foreach ($supported as $slug) {
                    $plugin = $plugins[$slug];
                    $is_active = defined($plugin['constant']);
                    $count = 0;

                    // Always check for data, regardless of plugin activation status
                    $has_data = $this->check_schema_data($slug, $count);

                    $supported_plugins[$slug] = [
                        'name' => $plugin['name'],
                        'key' => $slug,
                        'installed' => $is_active,
                        'has_data' => $has_data,
                        'count' => $count,
                        'version' => $is_active ? constant($plugin['constant']) : ''
                    ];
                }
                break;

            case 'indexation':
                // Check for actual per-post SEO data
                // Even if plugin is deactivated, we can still import the data
                $supported = ['yoast', 'rankmath', 'aioseo'];
                foreach ($supported as $slug) {
                    $plugin = $plugins[$slug];
                    $is_active = defined($plugin['constant']);
                    $count = 0;

                    // Always check for data, regardless of plugin activation status
                    $has_data = $this->check_indexation_data($slug, $count);

                    $supported_plugins[$slug] = [
                        'name' => $plugin['name'],
                        'key' => $slug,
                        'installed' => $is_active,
                        'has_data' => $has_data,
                        'count' => $count,
                        'version' => $is_active ? constant($plugin['constant']) : ''
                    ];
                }
                break;

            case 'primary_category':
                // Check for primary category data from other SEO plugins
                $supported = ['yoast', 'rankmath', 'aioseo'];
                foreach ($supported as $slug) {
                    $plugin = $plugins[$slug];
                    $is_active = defined($plugin['constant']);
                    $count = 0;

                    $has_data = $this->check_primary_category_data($slug, $count);

                    $supported_plugins[$slug] = [
                        'name' => $plugin['name'],
                        'key' => $slug,
                        'installed' => $is_active,
                        'has_data' => $has_data,
                        'count' => $count,
                        'version' => $is_active ? constant($plugin['constant']) : ''
                    ];
                }
                break;

            case 'seo_metadata':
                // Check for actual SEO metadata (titles and descriptions)
                // Even if plugin is deactivated, we can still import the data
                $supported = ['yoast', 'rankmath', 'aioseo'];
                foreach ($supported as $slug) {
                    $plugin = $plugins[$slug];
                    $is_active = defined($plugin['constant']);
                    $count = 0;

                    // Always check for data, regardless of plugin activation status
                    $has_data = $this->check_seo_metadata($slug, $count);

                    $supported_plugins[$slug] = [
                        'name' => $plugin['name'],
                        'key' => $slug,
                        'installed' => $is_active,
                        'has_data' => $has_data,
                        'count' => $count,
                        'version' => $is_active ? constant($plugin['constant']) : ''
                    ];
                }
                break;
        }

        return $supported_plugins;
    }

    /**
     * Check if indexation data exists for a plugin
     */
    private function check_indexation_data($plugin, &$count)
    {
        global $wpdb;

        $count = 0;

        switch ($plugin) {
            case 'yoast':
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
                $count = (int) $wpdb->get_var("
                    SELECT COUNT(DISTINCT post_id)
                    FROM {$wpdb->postmeta}
                    WHERE meta_key LIKE '_yoast_wpseo_%'
                    AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
                ");
                break;

            case 'rankmath':
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
                $count = (int) $wpdb->get_var($wpdb->prepare("
                    SELECT COUNT(DISTINCT post_id)
                    FROM {$wpdb->postmeta}
                    WHERE meta_key LIKE %s
                    AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
                ", 'rank_math_%'));
                break;

            case 'aioseo':
                $table = $wpdb->prefix . 'aioseo_posts';
                if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
                    $count = (int) $wpdb->get_var("
                        SELECT COUNT(post_id)
                        FROM {$wpdb->prefix}aioseo_posts
                        WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
                    ");
                }
                break;
        }

        return $count > 0;
    }

    /**
     * Check if schema data exists for a plugin
     *
     * Counts only records the importer would actually write: the source record
     * must hold a supported, populated schema, and the post must not already
     * carry a MetaSync schema block. The per-record rule is shared with the
     * importers (see the *_schema_source_is_supported() helpers and
     * post_has_metasync_schema()), so the count the Schema Settings page shows
     * is the count an import can deliver — the old detector counted every
     * provider row and reported posts that were silently skipped.
     */
    private function check_schema_data($plugin, &$count)
    {
        $count = 0;
        $importable_posts = [];

        switch ($plugin) {
            case 'yoast':
                foreach ($this->yoast_schema_candidate_rows(true) as $row) {
                    $post_id = (int) $row->post_id;
                    if (isset($importable_posts[$post_id])) {
                        continue;
                    }
                    if ($this->yoast_schema_source_is_supported($row->meta_key, $row->meta_value) !== null) {
                        $importable_posts[$post_id] = true;
                    }
                }
                break;

            case 'rankmath':
                foreach ($this->rankmath_schema_candidate_rows(true) as $row) {
                    $post_id = (int) $row->post_id;
                    if (isset($importable_posts[$post_id])) {
                        continue;
                    }
                    if ($this->rankmath_schema_source_is_supported($row->meta_key, $row->meta_value) !== null) {
                        $importable_posts[$post_id] = true;
                    }
                }
                break;

            case 'aioseo':
                // AIOSEO's `default` rows carry no per-post schema at all, so
                // they are filtered out before eligibility is even evaluated.
                foreach ($this->aioseo_schema_candidate_rows(true) as $row) {
                    $post_id = (int) $row->post_id;
                    if (isset($importable_posts[$post_id])) {
                        continue;
                    }
                    if ($this->aioseo_schema_source_is_supported($row->schema_type, $row->schema_type_options) !== null) {
                        $importable_posts[$post_id] = true;
                    }
                }
                break;
        }

        $count = count($importable_posts);

        return $count > 0;
    }

    /**
     * Yoast source rows that could hold per-post schema, for published posts.
     *
     * Shared by check_schema_data() and import_yoast_schema() so both look at
     * exactly the same candidate set.
     *
     * @param  bool $only_new Skip posts that already hold a MetaSync schema block.
     *                        Only the detector sets this: the importers must see
     *                        every candidate so they can report those posts as
     *                        already imported rather than silently dropping them.
     * @return array<object> Rows with post_id, meta_key and meta_value.
     */
    private function yoast_schema_candidate_rows($only_new = false)
    {
        global $wpdb;

        $query = "SELECT post_id, meta_key, meta_value
            FROM {$wpdb->postmeta}
            WHERE (meta_key = '_yoast_wpseo_schema' OR meta_key = '_yoast_wpseo_schema_article_type')
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            " . ($only_new ? $this->sql_exclude_posts_with_metasync_schema($wpdb->postmeta) : '') . "
            ORDER BY post_id ASC";

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists; table names come from wpdb, exclusion fragment is class-built SQL
        return (array) $wpdb->get_results($query);
    }

    /**
     * Rank Math source rows that could hold per-post schema, for published
     * posts. Shortcode schemas are excluded, as the importer always has.
     *
     * @param  bool $only_new Skip posts that already hold a MetaSync schema block.
     * @return array<object> Rows with post_id, meta_key and meta_value.
     */
    private function rankmath_schema_candidate_rows($only_new = false)
    {
        global $wpdb;

        $query = "SELECT pm.post_id, pm.meta_key, pm.meta_value
            FROM {$wpdb->postmeta} pm
            WHERE pm.meta_key LIKE 'rank_math_schema_%'
            AND pm.meta_key NOT LIKE 'rank_math_shortcode_schema_%'
            AND pm.meta_value != ''
            AND pm.meta_value != 'a:0:{}'
            AND pm.post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            " . ($only_new ? $this->sql_exclude_posts_with_metasync_schema('pm') : '') . "
            ORDER BY pm.post_id ASC";

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists; table names come from wpdb, exclusion fragment is class-built SQL
        return (array) $wpdb->get_results($query);
    }

    /**
     * AIOSEO source rows that could hold per-post schema, for published posts.
     *
     * Returns an empty array when the AIOSEO table is absent (the plugin was
     * never installed), matching the previous detector behaviour.
     *
     * @param  bool $only_new Skip posts that already hold a MetaSync schema block.
     * @return array<object> Rows with post_id, schema_type and schema_type_options.
     */
    private function aioseo_schema_candidate_rows($only_new = false)
    {
        global $wpdb;

        $table = $wpdb->prefix . 'aioseo_posts';
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) !== $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
            return [];
        }

        // AIOSEO writes a row for every post it tracks, and the overwhelming
        // majority carry schema_type 'default' with empty schema_type_options.
        // Those rows represent the post-type default, not per-post schema, so
        // they are dropped here rather than being reported as "unsupported" —
        // counting them is what made the card claim hundreds of ready posts
        // that no import could ever produce.
        $query = "SELECT post_id, schema_type, schema_type_options
            FROM {$table}
            WHERE schema_type IS NOT NULL
            AND schema_type != ''
            AND schema_type != 'default'
            AND schema_type_options IS NOT NULL
            AND schema_type_options NOT IN ('', '{}', '[]', 'null')
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            " . ($only_new ? $this->sql_exclude_posts_with_metasync_schema($table) : '') . "
            ORDER BY post_id ASC";

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists; table name comes from wpdb, exclusion fragment is class-built SQL
        return (array) $wpdb->get_results($query);
    }

    /**
     * SQL clause excluding posts that already hold a MetaSync schema block.
     *
     * Mirrors post_has_metasync_schema() in SQL: a metasync_schema_markup
     * value whose "types" array exists and is non-empty. Values without a
     * "types" list (an OTTO-deployed otto_jsonld block, for example) are
     * deliberately not excluded — the importer does not skip those either.
     * post_has_metasync_schema() is still evaluated per record in PHP, so the
     * two checks cannot drift apart.
     *
     * @param  string $alias Table name or alias the candidate query exposes post_id under.
     * @return string        "AND NOT EXISTS (...)" clause.
     */
    private function sql_exclude_posts_with_metasync_schema($alias)
    {
        global $wpdb;

        return "AND NOT EXISTS (
            SELECT 1
            FROM {$wpdb->postmeta} ms
            WHERE ms.post_id = {$alias}.post_id
            AND ms.meta_key = 'metasync_schema_markup'
            AND ms.meta_value LIKE '%s:5:\"types\";a:%'
            AND ms.meta_value NOT LIKE '%s:5:\"types\";a:0:{}%'
        )";
    }

    /**
     * Whether a post already holds a MetaSync schema block an import must not
     * overwrite.
     *
     * Same guard the importers have always applied: an existing
     * metasync_schema_markup whose "types" list is populated means the schema
     * was already migrated or set by hand, so the source record is skipped
     * and reported as already imported rather than overwritten.
     *
     * @param  int $post_id Post ID.
     * @return bool
     */
    private function post_has_metasync_schema($post_id)
    {
        $existing = get_post_meta($post_id, 'metasync_schema_markup', true);

        return is_array($existing) && !empty($existing['types']);
    }

    /**
     * Normalise a source schema type string to a supported MetaSync type.
     *
     * Shared by the Yoast and Rank Math eligibility checks and by the
     * converters, so "which source types can we actually carry" has a single
     * answer per provider.
     *
     * @param  string $raw_type Source @type / schema type, any case.
     * @return string|null      MetaSync type slug, or null when unsupported.
     */
    private function supported_schema_type($raw_type)
    {
        $type = strtolower(trim((string) $raw_type));
        if ($type === '') {
            return null;
        }

        $map = [
            'article'     => 'article',
            'newsarticle' => 'article',
            'blogposting' => 'article',
            'faqpage'     => 'FAQPage',
            'product'     => 'product',
            'recipe'      => 'recipe',
        ];

        return $map[$type] ?? null;
    }

    /**
     * Whether a Yoast source row holds schema the importer can convert.
     *
     * Returns the resolved MetaSync type when the row is supported, or null
     * when it must not be counted or imported (unsupported type, empty or
     * invalid payload). Used by both check_schema_data() and
     * import_yoast_schema() so the two cannot drift.
     *
     * @param  string $meta_key   Source meta key.
     * @param  string $meta_value Raw meta value.
     * @return string|null
     */
    private function yoast_schema_source_is_supported($meta_key, $meta_value)
    {
        if ($meta_key === '_yoast_wpseo_schema_article_type') {
            // Yoast Premium stores a bare type name; the importer builds an
            // article skeleton from it.
            return $this->supported_schema_type($meta_value);
        }

        if ($meta_key !== '_yoast_wpseo_schema') {
            return null;
        }

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

        $decoded = json_decode($value, true);
        if (!is_array($decoded) || $decoded === []) {
            return null;
        }

        foreach ($this->yoast_schema_graph_items($decoded) as $item) {
            if (!is_array($item) || !isset($item['@type'])) {
                continue;
            }

            $raw_type = $item['@type'];
            if (is_array($raw_type)) {
                $raw_type = reset($raw_type);
            }
            if (!is_string($raw_type) || $raw_type === '') {
                continue;
            }

            $supported = $this->supported_schema_type($raw_type);
            if ($supported === null) {
                continue;
            }

            // A FAQPage whose questions cannot be converted produces an empty
            // MetaSync block, so it must not be counted as importable either.
            if ($supported === 'FAQPage'
                && !$this->faq_questions_are_convertible($item['mainEntity'] ?? [], 'yoast')) {
                continue;
            }

            return $supported;
        }

        return null;
    }

    /**
     * Iterate the schema entries inside a decoded Yoast payload.
     *
     * Supports the two storage shapes Yoast has used: the current
     * `{"@graph": [...]}` wrapper and the legacy root array of entries.
     *
     * @param  array $decoded Decoded `_yoast_wpseo_schema` value.
     * @return array          Iterable list of schema item arrays.
     */
    private function yoast_schema_graph_items($decoded)
    {
        if (isset($decoded['@graph']) && is_array($decoded['@graph'])) {
            return $decoded['@graph'];
        }

        // A single schema object at the root: {"@type":"Article",...}.
        if (isset($decoded['@type'])) {
            return [$decoded];
        }

        // Legacy payload: a root array of entries, e.g.
        // [{"@type":"Article","headline":"..."}, ...]. Only treat it as such
        // when every element is an array — otherwise the value is an object
        // whose keys are property names, not entries, and no entry carries a
        // @type anyway.
        $is_entry_list = $decoded !== [];
        foreach ($decoded as $entry) {
            if (!is_array($entry)) {
                $is_entry_list = false;
                break;
            }
        }

        return $is_entry_list ? $decoded : [];
    }

    /**
     * Whether a Rank Math source row holds schema the importer can convert.
     *
     * Returns the resolved MetaSync type when supported, null otherwise. The
     * type lives in the meta key (rank_math_schema_Article), mirroring
     * convert_rankmath_schema_to_metasync().
     *
     * @param  string $meta_key   Source meta key.
     * @param  string $meta_value Raw meta value.
     * @return string|null
     */
    private function rankmath_schema_source_is_supported($meta_key, $meta_value)
    {
        if (strpos((string) $meta_key, 'rank_math_schema_') !== 0) {
            return null;
        }

        $schema_type = str_replace('rank_math_schema_', '', (string) $meta_key);
        $supported   = $this->supported_schema_type($schema_type);

        if ($supported === null) {
            return null;
        }

        $decoded = maybe_unserialize($meta_value);
        if (!is_array($decoded) || $decoded === []) {
            return null;
        }

        // Keep the check in step with the converter: a FAQPage whose questions
        // cannot be converted would yield an empty MetaSync block.
        if ($supported === 'FAQPage'
            && !$this->faq_questions_are_convertible($decoded['questions'] ?? [], 'rankmath')) {
            return null;
        }

        return $supported;
    }

    /**
     * Whether an AIOSEO row holds per-post schema the importer can convert.
     *
     * Returns the resolved MetaSync type when supported, null otherwise.
     * `schema_type = 'default'` rows — the post-type default AIOSEO stamps on
     * every tracked post — never reach this check from the candidate queries,
     * but it is enforced here as well so the importer cannot regress into
     * reporting them as "unsupported" source schemas.
     *
     * @param  string $schema_type         Raw schema_type column value.
     * @param  string $schema_type_options Raw schema_type_options JSON.
     * @return string|null
     */
    private function aioseo_schema_source_is_supported($schema_type, $schema_type_options)
    {
        $type = strtolower(trim((string) $schema_type));

        if ($type === '' || $type === 'default' || $type === 'none') {
            return null;
        }

        $supported = $this->supported_schema_type($type);
        if ($supported === null) {
            return null;
        }

        $decoded = json_decode(trim((string) $schema_type_options), true);
        if (!is_array($decoded) || $decoded === []) {
            return null;
        }

        // As above: an unconvertible FAQPage must not be counted as importable.
        if ($supported === 'FAQPage'
            && !$this->faq_questions_are_convertible($decoded['questions'] ?? [], 'aioseo')) {
            return null;
        }

        return $supported;
    }

    /**
     * Whether a FAQ question list holds at least one entry the converter can
     * turn into a MetaSync FAQ item, for the given provider's key shape.
     *
     * Eligibility and conversion must agree: a source counted as importable
     * has to produce a non-empty MetaSync block, or the detector over-reports
     * again — the very defect this class of fix exists to close.
     *
     * A scalar `mainEntity`/`questions` value (corrupt or foreign meta) is
     * simply not convertible; it must never fatal the eligibility check,
     * which runs on every render of the Schema import tab.
     *
     * @param  mixed  $questions Question list from the source record.
     * @param  string $provider  'yoast', 'rankmath' or 'aioseo'.
     * @return bool
     */
    private function faq_questions_are_convertible($questions, $provider)
    {
        if (!is_array($questions)) {
            return false;
        }

        foreach ($questions as $question) {
            if (!is_array($question)) {
                continue;
            }

            if ($provider === 'yoast') {
                if (isset($question['name'], $question['acceptedAnswer']['text'])) {
                    return true;
                }
            } elseif ($provider === 'rankmath') {
                if (isset($question['name'], $question['text'])) {
                    return true;
                }
            } elseif (isset($question['question'], $question['answer'])) {
                return true;
            }
        }

        return false;
    }

    /**
     * Check if SEO metadata (titles and descriptions) exists for a plugin
     */
    private function check_seo_metadata($plugin, &$count)
    {
        global $wpdb;

        $count = 0;

        switch ($plugin) {
            case 'yoast':
                // Check for Yoast SEO title or description
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
                $count = (int) $wpdb->get_var("
                    SELECT COUNT(DISTINCT post_id)
                    FROM {$wpdb->postmeta}
                    WHERE (meta_key = '_yoast_wpseo_title' OR meta_key = '_yoast_wpseo_metadesc')
                    AND meta_value != ''
                    AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
                ");
                break;

            case 'rankmath':
                // Check for Rank Math title or description
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
                $count = (int) $wpdb->get_var("
                    SELECT COUNT(DISTINCT post_id)
                    FROM {$wpdb->postmeta}
                    WHERE (meta_key = 'rank_math_title' OR meta_key = 'rank_math_description')
                    AND meta_value != ''
                    AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
                ");
                break;

            case 'aioseo':
                // Check for AIOSEO title or description in their custom table
                $table = $wpdb->prefix . 'aioseo_posts';
                if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) === $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
                    $count = (int) $wpdb->get_var("
                        SELECT COUNT(post_id)
                        FROM {$wpdb->prefix}aioseo_posts
                        WHERE ((title IS NOT NULL AND title != '') OR (description IS NOT NULL AND description != ''))
                        AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
                    ");

                    // Per-post overrides are the exception. AIOSEO also serves a
                    // title and description for every post from its site-wide
                    // templates, and those are what the import mainly picks up —
                    // so a site that never overrode an individual post still has
                    // plenty to migrate. Counting overrides alone reported zero
                    // there, which rendered the card "Unavailable" with a disabled
                    // button and made the import impossible to start.
                    $templates = $this->get_aioseo_post_type_templates();
                    if (!empty($templates)) {
                        $post_types        = array_keys($templates);
                        $type_placeholders = implode(', ', array_fill(0, count($post_types), '%s'));

                        $template_count = (int) $wpdb->get_var($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
                            "SELECT COUNT(*)
                             FROM {$wpdb->posts}
                             WHERE post_status = 'publish'
                             AND post_type IN ({$type_placeholders})",
                            $post_types
                        ));

                        // Take the larger of the two. Templates configured for a
                        // post type with no published posts would otherwise report
                        // zero and disable the card, hiding override rows that do
                        // exist — the same dead end from the other direction.
                        $count = max($count, $template_count);
                    }
                }
                break;
        }

        return $count > 0;
    }

    /**
     * Check if primary category data exists for a plugin
     */
    private function check_primary_category_data($plugin, &$count)
    {
        global $wpdb;

        $count = 0;

        switch ($plugin) {
            case 'yoast':
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
                $count = (int) $wpdb->get_var("
                    SELECT COUNT(DISTINCT post_id)
                    FROM {$wpdb->postmeta}
                    WHERE meta_key = '_yoast_wpseo_primary_category'
                    AND meta_value != ''
                    AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
                ");
                break;

            case 'rankmath':
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
                $count = (int) $wpdb->get_var("
                    SELECT COUNT(DISTINCT post_id)
                    FROM {$wpdb->postmeta}
                    WHERE meta_key = 'rank_math_primary_category'
                    AND meta_value != ''
                    AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
                ");
                break;

            case 'aioseo':
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
                $count = (int) $wpdb->get_var("
                    SELECT COUNT(DISTINCT post_id)
                    FROM {$wpdb->postmeta}
                    WHERE meta_key = '_aioseo_primary_category'
                    AND meta_value != ''
                    AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
                ");
                break;
        }

        return $count > 0;
    }

    /**
     * Import primary category from another SEO plugin
     *
     * @param string $plugin Plugin slug (yoast, rankmath, aioseo)
     * @param array  $options Import options
     * @return array Result array with success, imported, skipped, total, etc.
     */
    public function import_primary_category($plugin, $options = [])
    {
        $defaults = [
            'overwrite_existing' => false,
            'batch_size' => 50,
            'offset' => 0,
        ];
        $options = array_merge($defaults, $options);

        if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'])) {
            return [
                'success' => false,
                'message' => 'Invalid plugin specified.',
            ];
        }

        switch ($plugin) {
            case 'yoast':
                return $this->import_yoast_primary_category($options);
            case 'rankmath':
                return $this->import_rankmath_primary_category($options);
            case 'aioseo':
                return $this->import_aioseo_primary_category($options);
        }

        return [
            'success' => false,
            'message' => 'Unknown error occurred.',
        ];
    }

    /**
     * Import primary category from Yoast SEO
     */
    private function import_yoast_primary_category($options)
    {
        global $wpdb;

        $batch_size = intval($options['batch_size']);
        $offset = intval($options['offset']);
        $overwrite = (bool) $options['overwrite_existing'];

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $total_posts = (int) $wpdb->get_var("
            SELECT COUNT(DISTINCT post_id)
            FROM {$wpdb->postmeta}
            WHERE meta_key = '_yoast_wpseo_primary_category'
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
        ");

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $posts = $wpdb->get_results($wpdb->prepare("
            SELECT DISTINCT post_id
            FROM {$wpdb->postmeta}
            WHERE meta_key = '_yoast_wpseo_primary_category'
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            ORDER BY post_id ASC
            LIMIT %d OFFSET %d
        ", $batch_size, $offset));

        $imported_count = 0;
        $skipped_count = 0;

        foreach ($posts as $post_obj) {
            $post_id = $post_obj->post_id;
            $term_id = absint(get_post_meta($post_id, '_yoast_wpseo_primary_category', true));

            if ($term_id <= 0 || !get_term($term_id, 'category')) {
                $skipped_count++;
                continue;
            }

            $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
            if ($existing > 0 && !$overwrite) {
                $skipped_count++;
                continue;
            }

            update_post_meta($post_id, '_metasync_primary_category', $term_id);
            $imported_count++;
        }

        $processed = $offset + count($posts);
        $has_more = $processed < $total_posts;

        return [
            'success' => true,
            'imported' => $imported_count,
            'skipped' => $skipped_count,
            'total' => $total_posts,
            'has_more' => $has_more,
        ];
    }

    /**
     * Import primary category from Rank Math
     */
    private function import_rankmath_primary_category($options)
    {
        global $wpdb;

        $batch_size = intval($options['batch_size']);
        $offset = intval($options['offset']);
        $overwrite = (bool) $options['overwrite_existing'];

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $total_posts = (int) $wpdb->get_var("
            SELECT COUNT(DISTINCT post_id)
            FROM {$wpdb->postmeta}
            WHERE meta_key = 'rank_math_primary_category'
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
        ");

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $posts = $wpdb->get_results($wpdb->prepare("
            SELECT DISTINCT post_id
            FROM {$wpdb->postmeta}
            WHERE meta_key = 'rank_math_primary_category'
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            ORDER BY post_id ASC
            LIMIT %d OFFSET %d
        ", $batch_size, $offset));

        $imported_count = 0;
        $skipped_count = 0;

        foreach ($posts as $post_obj) {
            $post_id = $post_obj->post_id;
            $term_id = absint(get_post_meta($post_id, 'rank_math_primary_category', true));

            if ($term_id <= 0 || !get_term($term_id, 'category')) {
                $skipped_count++;
                continue;
            }

            $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
            if ($existing > 0 && !$overwrite) {
                $skipped_count++;
                continue;
            }

            update_post_meta($post_id, '_metasync_primary_category', $term_id);
            $imported_count++;
        }

        $processed = $offset + count($posts);
        $has_more = $processed < $total_posts;

        return [
            'success' => true,
            'imported' => $imported_count,
            'skipped' => $skipped_count,
            'total' => $total_posts,
            'has_more' => $has_more,
        ];
    }

    /**
     * Import primary category from AIOSEO
     */
    private function import_aioseo_primary_category($options)
    {
        global $wpdb;

        $batch_size = intval($options['batch_size']);
        $offset = intval($options['offset']);
        $overwrite = (bool) $options['overwrite_existing'];

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $total_posts = (int) $wpdb->get_var("
            SELECT COUNT(DISTINCT post_id)
            FROM {$wpdb->postmeta}
            WHERE meta_key = '_aioseo_primary_category'
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
        ");

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $posts = $wpdb->get_results($wpdb->prepare("
            SELECT DISTINCT post_id
            FROM {$wpdb->postmeta}
            WHERE meta_key = '_aioseo_primary_category'
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            ORDER BY post_id ASC
            LIMIT %d OFFSET %d
        ", $batch_size, $offset));

        $imported_count = 0;
        $skipped_count = 0;

        foreach ($posts as $post_obj) {
            $post_id = $post_obj->post_id;
            $term_id = absint(get_post_meta($post_id, '_aioseo_primary_category', true));

            if ($term_id <= 0 || !get_term($term_id, 'category')) {
                $skipped_count++;
                continue;
            }

            $existing = (int) get_post_meta($post_id, '_metasync_primary_category', true);
            if ($existing > 0 && !$overwrite) {
                $skipped_count++;
                continue;
            }

            update_post_meta($post_id, '_metasync_primary_category', $term_id);
            $imported_count++;
        }

        $processed = $offset + count($posts);
        $has_more = $processed < $total_posts;

        return [
            'success' => true,
            'imported' => $imported_count,
            'skipped' => $skipped_count,
            'total' => $total_posts,
            'has_more' => $has_more,
        ];
    }

    /**
     * Import Redirections
     */
    public function import_redirections($plugin)
    {
        if (!$this->redirection_importer) {
            return ['success' => false, 'message' => 'Redirection database not initialized.'];
        }
        return $this->redirection_importer->import_from_plugin($plugin);
    }

    /**
     * Import Sitemap Settings
     */
    public function import_sitemap($plugin)
    {
        $imported = false;
        $message = '';

        switch ($plugin) {
            case 'yoast':
                $options = get_option('wpseo_xml');
                if ($options && isset($options['enablexmlsitemap'])) {
                    // Yoast stores sitemap settings in wpseo_xml option
                    // Metasync sitemap is auto-generated, so we just acknowledge the import
                    $message = 'Sitemap settings imported from Yoast.';
                    $imported = true;
                }
                break;

            case 'rankmath':
                $options = get_option('rank-math-options-sitemap');
                if ($options) {
                    // Import logic here
                    $message = 'Sitemap settings imported from Rank Math.';
                    $imported = true;
                }
                break;

            case 'aioseo':
                $options = get_option('aioseo_options');
                if ($options && isset($options['sitemap'])) {
                    // Import logic here
                    $message = 'Sitemap settings imported from AIOSEO.';
                    $imported = true;
                }
                break;
        }

        if (!$imported) {
            return ['success' => false, 'message' => 'No sitemap settings found or plugin not active.'];
        }

        return ['success' => true, 'message' => $message];
    }

    /**
     * Import Robots.txt
     */
    public function import_robots($plugin)
    {
        $content = '';
        
        switch ($plugin) {
            case 'yoast':
                // Yoast doesn't store robots.txt in DB, it edits the file.
                // But it might have settings for it.
                // If we are "importing", we might just want to read the current file if managed by them?
                // Actually, if they have a custom robots.txt editor, they might store it.
                // Yoast uses the file system directly.
                $content = $this->get_robots_content_from_file();
                break;

            case 'rankmath':
                $options = get_option('rank-math-options-general');
                if (isset($options['robots_txt_content'])) {
                    $content = $options['robots_txt_content'];
                } else {
                    $content = $this->get_robots_content_from_file();
                }
                break;

            case 'aioseo':
                $options = get_option('aioseo_options');
                if (isset($options['tools']['robots']['rules'])) {
                    // AIOSEO stores rules as array, need to reconstruct
                    // For simplicity, let's try reading the file first as it's the source of truth
                    $content = $this->get_robots_content_from_file();
                }
                break;
        }

        if (empty($content)) {
             return ['success' => false, 'message' => 'No robots.txt content found.'];
        }

        // Save to Metasync Robots.txt
        // Load the class if not already loaded
        if (!class_exists('Metasync_Robots_Txt')) {
            require_once plugin_dir_path(dirname(__FILE__)) . 'robots-txt/class-metasync-robots-txt.php';
        }

        $robots_class = Metasync_Robots_Txt::get_instance();
        $result = $robots_class->write_robots_file($content);

        if (is_wp_error($result)) {
            return ['success' => false, 'message' => $result->get_error_message()];
        }

        return ['success' => true, 'message' => 'Robots.txt content imported successfully.'];
    }

    private function get_robots_content_from_file() {
        $robots_file = ABSPATH . 'robots.txt';
        if (file_exists($robots_file)) {
            return file_get_contents($robots_file);
        }
        return '';
    }

    /**
     * Import Indexation Options (Per-Post Robots Meta)
     */
    public function import_indexation($plugin, $options = [])
    {
        $defaults = ['batch_size' => 50, 'offset' => 0];
        $options = array_merge($defaults, $options);

        switch ($plugin) {
            case 'yoast':
                return $this->import_yoast_indexation($options);

            case 'rankmath':
                return $this->import_rankmath_indexation($options);

            case 'aioseo':
                return $this->import_aioseo_indexation($options);

            default:
                return ['success' => false, 'message' => 'Invalid plugin specified.'];
        }
    }

    /**
     * Import per-post indexation settings from Yoast SEO
     */
    private function import_yoast_indexation($options = [])
    {
        global $wpdb;
        $imported_count = 0;

        $batch_size = intval($options['batch_size']);
        $offset = intval($options['offset']);

        // Get total count (for progress tracking)
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $total = (int) $wpdb->get_var($wpdb->prepare("
            SELECT COUNT(DISTINCT post_id)
            FROM {$wpdb->postmeta}
            WHERE meta_key LIKE %s
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
        ", '_yoast_wpseo_%'));

        // Get batch of posts with Yoast robots meta
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $posts = $wpdb->get_results($wpdb->prepare("
            SELECT DISTINCT post_id
            FROM {$wpdb->postmeta}
            WHERE meta_key LIKE %s
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            ORDER BY post_id ASC
            LIMIT %d OFFSET %d
        ", '_yoast_wpseo_%', $batch_size, $offset));

        foreach ($posts as $post_obj) {
            $post_id = $post_obj->post_id;
            $has_changes = false;

            // Get existing Metasync robots meta
            $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
            if (!is_array($metasync_robots)) {
                $metasync_robots = [];
            }

            // Import noindex
            $yoast_noindex = get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true);
            if ($yoast_noindex === '1' && !isset($metasync_robots['noindex'])) {
                $metasync_robots['noindex'] = 'noindex';
                $has_changes = true;
            } elseif ($yoast_noindex === '2' && isset($metasync_robots['noindex'])) {
                // '2' means 'index' in Yoast. Index is the implicit default, so
                // clear any stored noindex rather than recording a redundant flag.
                unset($metasync_robots['noindex']);
                $has_changes = true;
            }

            // Import nofollow
            $yoast_nofollow = get_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', true);
            if ($yoast_nofollow === '1' && !isset($metasync_robots['nofollow'])) {
                $metasync_robots['nofollow'] = 'nofollow';
                $has_changes = true;
            }

            // Import advanced robots (noarchive, nosnippet, noimageindex)
            $yoast_adv = get_post_meta($post_id, '_yoast_wpseo_meta-robots-adv', true);
            // Normalise the meta value to a string before calling explode().
            // Some sites store malformed/legacy data where this meta value is
            // an array, which would otherwise throw a TypeError on explode().
            if (is_array($yoast_adv)) {
                $yoast_adv = implode(',', $yoast_adv);
            } elseif (!is_string($yoast_adv)) {
                $yoast_adv = '';
            }
            if (!empty($yoast_adv)) {
                $adv_directives = explode(',', $yoast_adv);
                foreach ($adv_directives as $directive) {
                    $directive = trim($directive);
                    if (in_array($directive, ['noarchive', 'nosnippet', 'noimageindex']) && !isset($metasync_robots[$directive])) {
                        $metasync_robots[$directive] = $directive;
                        $has_changes = true;
                    }
                }
            }

            // Also write to _metasync_robots_advanced JSON
            $robots_advanced = [];
            if (!empty($yoast_adv)) {
                $adv_directives = array_map('trim', explode(',', $yoast_adv));
                foreach (['noarchive', 'nosnippet', 'noimageindex'] as $dir) {
                    if (in_array($dir, $adv_directives, true)) {
                        $robots_advanced[$dir] = true;
                    }
                }
            }
            // nofollow from Yoast
            if ($yoast_nofollow === '1') {
                $robots_advanced['nofollow'] = true;
            }
            if (!empty($robots_advanced)) {
                update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
            }

            // Import canonical URL
            $yoast_canonical = get_post_meta($post_id, '_yoast_wpseo_canonical', true);
            if (!empty($yoast_canonical)) {
                $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
                if (empty($existing_canonical)) {
                    // Validate: never import a corrupted value such as
                    // "http://Array" from third-party storage.
                    $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($yoast_canonical);
                    if ($clean_canonical !== '') {
                        update_post_meta($post_id, 'meta_canonical', $clean_canonical);
                        $has_changes = true;
                    }
                }
            }

            // Save Metasync robots meta if changes were made
            if ($has_changes) {
                if (!empty($metasync_robots)) {
                    update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
                }
                $imported_count++;
            }

            // Flush per-post object cache to prevent unbounded memory growth across batches
            clean_post_cache($post_id);
        }

        $processed = $offset + count($posts);
        $is_complete = $processed >= $total;

        return [
            'success' => true,
            'imported' => $imported_count,
            'skipped' => count($posts) - $imported_count,
            'total' => $total,
            'processed' => $processed,
            'is_complete' => $is_complete,
            'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
            'message' => $is_complete
                ? "Import complete! Imported {$imported_count} posts."
                : "Processing... {$imported_count} imported."
        ];
    }

    /**
     * Import per-post indexation settings from Rank Math
     */
    private function import_rankmath_indexation($options = [])
    {
        global $wpdb;
        $imported_count = 0;

        $batch_size = intval($options['batch_size']);
        $offset = intval($options['offset']);

        // Get total count (for progress tracking)
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $total = (int) $wpdb->get_var($wpdb->prepare("
            SELECT COUNT(DISTINCT post_id)
            FROM {$wpdb->postmeta}
            WHERE meta_key LIKE %s
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
        ", 'rank_math_%'));

        // Get batch of posts with Rank Math robots meta
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $posts = $wpdb->get_results($wpdb->prepare("
            SELECT DISTINCT post_id
            FROM {$wpdb->postmeta}
            WHERE meta_key LIKE %s
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            ORDER BY post_id ASC
            LIMIT %d OFFSET %d
        ", 'rank_math_%', $batch_size, $offset));

        foreach ($posts as $post_obj) {
            $post_id = $post_obj->post_id;
            $has_changes = false;

            // Get existing Metasync robots meta
            $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
            if (!is_array($metasync_robots)) {
                $metasync_robots = [];
            }

            // Import robots array
            $rm_robots = get_post_meta($post_id, 'rank_math_robots', true);
            if (is_array($rm_robots)) {
                // Rank Math stores as array like ['noindex', 'nofollow']
                foreach ($rm_robots as $directive) {
                    $directive = strtolower(trim($directive));
                    if (in_array($directive, ['index', 'noindex', 'nofollow', 'noarchive', 'nosnippet', 'noimageindex']) && !isset($metasync_robots[$directive])) {
                        $metasync_robots[$directive] = $directive;
                        $has_changes = true;
                    }
                }
            }

            // Import advanced robots
            $rm_adv_robots = get_post_meta($post_id, 'rank_math_advanced_robots', true);
            if (is_array($rm_adv_robots)) {
                foreach ($rm_adv_robots as $directive) {
                    $directive = strtolower(trim($directive));
                    if (in_array($directive, ['noarchive', 'nosnippet', 'noimageindex', 'max-snippet', 'max-video-preview', 'max-image-preview']) && !isset($metasync_robots[$directive])) {
                        $metasync_robots[$directive] = $directive;
                        $has_changes = true;
                    }
                }
            }

            // Parse max-* values and write _metasync_robots_advanced JSON
            $robots_advanced = [];
            // Boolean directives from rank_math_robots
            if (is_array($rm_robots)) {
                foreach (['nofollow', 'noarchive', 'nosnippet', 'noimageindex'] as $dir) {
                    if (in_array($dir, $rm_robots, true)) {
                        $robots_advanced[$dir] = true;
                    }
                }
            }
            // max-* directives from rank_math_advanced_robots
            if (is_array($rm_adv_robots)) {
                foreach ($rm_adv_robots as $key => $value) {
                    // Values are formatted like "max-snippet:-1"
                    if (strpos($key, 'max-snippet') !== false && strpos($value, ':') !== false) {
                        $robots_advanced['max_snippet'] = (int) explode(':', $value)[1];
                    }
                    if (strpos($key, 'max-image-preview') !== false && strpos($value, ':') !== false) {
                        $robots_advanced['max_image_preview'] = explode(':', $value)[1];
                    }
                    if (strpos($key, 'max-video-preview') !== false && strpos($value, ':') !== false) {
                        $robots_advanced['max_video_preview'] = (int) explode(':', $value)[1];
                    }
                }
            }
            if (!empty($robots_advanced)) {
                update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
            }

            // Import canonical URL
            $rm_canonical = get_post_meta($post_id, 'rank_math_canonical_url', true);
            if (!empty($rm_canonical)) {
                $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
                if (empty($existing_canonical)) {
                    // Validate: never import a corrupted value such as
                    // "http://Array" from third-party storage.
                    $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($rm_canonical);
                    if ($clean_canonical !== '') {
                        update_post_meta($post_id, 'meta_canonical', $clean_canonical);
                        $has_changes = true;
                    }
                }
            }

            // Save Metasync robots meta if changes were made
            if ($has_changes) {
                if (!empty($metasync_robots)) {
                    update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
                }
                $imported_count++;
            }

            // Flush per-post object cache to prevent unbounded memory growth across batches
            clean_post_cache($post_id);
        }

        $processed = $offset + count($posts);
        $is_complete = $processed >= $total;

        return [
            'success' => true,
            'imported' => $imported_count,
            'skipped' => count($posts) - $imported_count,
            'total' => $total,
            'processed' => $processed,
            'is_complete' => $is_complete,
            'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
            'message' => $is_complete
                ? "Import complete! Imported {$imported_count} posts."
                : "Processing... {$imported_count} imported."
        ];
    }

    /**
     * Import per-post indexation settings from AIOSEO
     */
    private function import_aioseo_indexation($options = [])
    {
        global $wpdb;
        $imported_count = 0;

        $batch_size = intval($options['batch_size']);
        $offset = intval($options['offset']);

        // AIOSEO stores data in a custom table
        $aioseo_table = $wpdb->prefix . 'aioseo_posts';

        // Check if table exists
        $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($aioseo_table))) === $aioseo_table; // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists

        if (!$table_exists) {
            return ['success' => false, 'message' => 'AIOSEO table not found.'];
        }

        // Get total count (for progress tracking)
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $total = (int) $wpdb->get_var("
            SELECT COUNT(*)
            FROM {$wpdb->prefix}aioseo_posts
            WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
        ");

        // Get batch of posts with AIOSEO settings
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $posts = $wpdb->get_results($wpdb->prepare("
            SELECT post_id, robots_default, robots_noindex, robots_nofollow,
                   robots_noarchive, robots_nosnippet, robots_noimageindex,
                   robots_max_snippet, robots_max_imagepreview, robots_max_videopreview,
                   canonical_url
            FROM {$wpdb->prefix}aioseo_posts
            WHERE post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            ORDER BY post_id ASC
            LIMIT %d OFFSET %d
        ", $batch_size, $offset));

        foreach ($posts as $aioseo_data) {
            $post_id = $aioseo_data->post_id;
            $has_changes = false;

            // Get existing Metasync robots meta
            $metasync_robots = get_post_meta($post_id, 'metasync_common_robots', true);
            if (!is_array($metasync_robots)) {
                $metasync_robots = [];
            }

            // Only import if not using default (robots_default = 0)
            if ($aioseo_data->robots_default == 0) {
                // Import noindex
                if ($aioseo_data->robots_noindex == 1 && !isset($metasync_robots['noindex'])) {
                    $metasync_robots['noindex'] = 'noindex';
                    $has_changes = true;
                }

                // Import nofollow
                if ($aioseo_data->robots_nofollow == 1 && !isset($metasync_robots['nofollow'])) {
                    $metasync_robots['nofollow'] = 'nofollow';
                    $has_changes = true;
                }

                // Import noarchive
                if ($aioseo_data->robots_noarchive == 1 && !isset($metasync_robots['noarchive'])) {
                    $metasync_robots['noarchive'] = 'noarchive';
                    $has_changes = true;
                }

                // Import nosnippet
                if ($aioseo_data->robots_nosnippet == 1 && !isset($metasync_robots['nosnippet'])) {
                    $metasync_robots['nosnippet'] = 'nosnippet';
                    $has_changes = true;
                }

                // Import noimageindex
                if ($aioseo_data->robots_noimageindex == 1 && !isset($metasync_robots['noimageindex'])) {
                    $metasync_robots['noimageindex'] = 'noimageindex';
                    $has_changes = true;
                }
            }

            // Write _metasync_robots_advanced JSON
            $robots_advanced = [];
            if ($aioseo_data->robots_default == 0) {
                if ($aioseo_data->robots_nofollow == 1) $robots_advanced['nofollow'] = true;
                if ($aioseo_data->robots_noarchive == 1) $robots_advanced['noarchive'] = true;
                if ($aioseo_data->robots_nosnippet == 1) $robots_advanced['nosnippet'] = true;
                if ($aioseo_data->robots_noimageindex == 1) $robots_advanced['noimageindex'] = true;
            }
            if (isset($aioseo_data->robots_max_snippet) && is_numeric($aioseo_data->robots_max_snippet)) {
                $robots_advanced['max_snippet'] = (int) $aioseo_data->robots_max_snippet;
            }
            if (!empty($aioseo_data->robots_max_imagepreview)) {
                $robots_advanced['max_image_preview'] = $aioseo_data->robots_max_imagepreview;
            }
            if (isset($aioseo_data->robots_max_videopreview) && is_numeric($aioseo_data->robots_max_videopreview)) {
                $robots_advanced['max_video_preview'] = (int) $aioseo_data->robots_max_videopreview;
            }
            if (!empty($robots_advanced)) {
                update_post_meta($post_id, '_metasync_robots_advanced', wp_json_encode($robots_advanced));
                $has_changes = true;
            }

            // Import canonical URL
            if (!empty($aioseo_data->canonical_url)) {
                $existing_canonical = get_post_meta($post_id, 'meta_canonical', true);
                if (empty($existing_canonical)) {
                    // Validate: never import a corrupted value such as
                    // "http://Array" from third-party storage.
                    $clean_canonical = Metasync_Canonical_Sanitizer::sanitize_for_save($aioseo_data->canonical_url);
                    if ($clean_canonical !== '') {
                        update_post_meta($post_id, 'meta_canonical', $clean_canonical);
                        $has_changes = true;
                    }
                }
            }

            // Save Metasync robots meta if changes were made
            if ($has_changes) {
                if (!empty($metasync_robots)) {
                    update_post_meta($post_id, 'metasync_common_robots', $metasync_robots);
                }
                $imported_count++;
            }

            // Flush per-post object cache to prevent unbounded memory growth across batches
            clean_post_cache($post_id);
        }

        $processed = $offset + count($posts);
        $is_complete = $processed >= $total;

        return [
            'success' => true,
            'imported' => $imported_count,
            'skipped' => count($posts) - $imported_count,
            'total' => $total,
            'processed' => $processed,
            'is_complete' => $is_complete,
            'progress_percent' => $total > 0 ? round(($processed / $total) * 100) : 100,
            'message' => $is_complete
                ? "Import complete! Imported {$imported_count} posts."
                : "Processing... {$imported_count} imported."
        ];
    }

    /**
     * Import Schema Settings (Per-Post Schema)
     *
     * Composes the user-facing result from the importer's breakdown so the
     * message states what actually happened: no source schema, everything
     * already migrated, everything unsupported/empty, or an import — including
     * the partial case where some posts were skipped alongside the imports.
     */
    public function import_schema($plugin)
    {
        switch ($plugin) {
            case 'yoast':
                $breakdown = $this->import_yoast_schema();
                break;

            case 'rankmath':
                $breakdown = $this->import_rankmath_schema();
                break;

            case 'aioseo':
                $breakdown = $this->import_aioseo_schema();
                break;

            default:
                return ['success' => false, 'message' => 'Invalid plugin specified.'];
        }

        $imported         = (int) $breakdown['imported'];
        $already_imported = (int) $breakdown['already_imported'];
        $unsupported      = (int) $breakdown['unsupported'];
        $candidates       = (int) $breakdown['total_candidates'];

        // No source rows at all — nothing was ever detected to begin with.
        if ($candidates === 0) {
            return [
                'success'   => false,
                'message'   => 'No post-level schema settings found to import.',
                'breakdown' => $breakdown,
            ];
        }

        if ($imported === 0) {
            // Every eligible source record is already in MetaSync.
            if ($already_imported > 0 && $unsupported === 0) {
                return [
                    'success'   => false,
                    'message'   => sprintf(
                        'All %d eligible post(s) already have MetaSync schema — nothing left to import.',
                        $already_imported
                    ),
                    'breakdown' => $breakdown,
                ];
            }

            // Source schema exists, but none of it maps to a supported type.
            if ($unsupported > 0 && $already_imported === 0) {
                return [
                    'success'   => false,
                    'message'   => sprintf(
                        'Found %d post(s) with schema settings, but none use a supported schema type '
                        . '(Article, FAQPage, Product, Recipe) or their contents were empty.',
                        $unsupported
                    ),
                    'breakdown' => $breakdown,
                ];
            }

            // Both skip reasons applied, nothing imported.
            return [
                'success'   => false,
                'message'   => sprintf(
                    'No schema settings imported: %d post(s) already had MetaSync schema and %d post(s) had unsupported or empty schema settings.',
                    $already_imported,
                    $unsupported
                ),
                'breakdown' => $breakdown,
            ];
        }

        $message = sprintf('Successfully imported schema settings for %d post(s).', $imported);

        if ($already_imported > 0) {
            $message .= sprintf(' Skipped %d post(s) that already had MetaSync schema.', $already_imported);
        }
        if ($unsupported > 0) {
            $message .= sprintf(' Skipped %d post(s) with unsupported or empty schema settings.', $unsupported);
        }

        return ['success' => true, 'message' => $message, 'breakdown' => $breakdown];
    }

    /**
     * Fresh per-import counters shared by the three schema importers.
     *
     * `total_candidates` counts distinct posts the importer looked at, so a
     * post with several source rows is one candidate. Posts already holding a
     * MetaSync schema count as `already_imported`; source rows that carry no
     * supported, populated schema count as `unsupported`.
     *
     * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
     */
    private function empty_schema_breakdown()
    {
        return [
            'imported'         => 0,
            'already_imported' => 0,
            'unsupported'      => 0,
            'total_candidates' => 0,
        ];
    }

    /**
     * Import per-post schema from Yoast SEO
     *
     * Rows are grouped per post first, because a post can carry both source
     * keys and the outcome must be decided once per post: a MetaSync schema
     * that already exists wins outright; otherwise the full JSON payload under
     * `_yoast_wpseo_schema` is preferred, and only when it yields nothing does
     * the bare Premium article type under `_yoast_wpseo_schema_article_type`
     * produce a placeholder article schema. Every other combination is
     * unsupported.
     *
     * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
     */
    private function import_yoast_schema()
    {
        $breakdown = $this->empty_schema_breakdown();

        // post_id => [meta_key => row]
        $rows_by_post = [];
        foreach ($this->yoast_schema_candidate_rows() as $row) {
            $rows_by_post[(int) $row->post_id][$row->meta_key] = $row;
        }

        foreach ($rows_by_post as $post_id => $rows) {
            // A MetaSync schema that is already there outranks whatever the
            // source plugin holds — the schema migration ran before (or an
            // editor set it by hand) and overwriting it is not this tool's job.
            $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
            if ($this->post_has_metasync_schema($post_id)) {
                $breakdown['already_imported']++;
                continue;
            }

            $schema_row       = $rows['_yoast_wpseo_schema'] ?? null;
            $article_type_row = $rows['_yoast_wpseo_schema_article_type'] ?? null;

            // Full JSON payload first (free version, or the legacy root array).
            if ($schema_row !== null
                && $this->yoast_schema_source_is_supported($schema_row->meta_key, $schema_row->meta_value) !== null) {
                $yoast_schema = json_decode((string) ($schema_row->meta_value ?? ''), true);
                $metasync_schema = is_array($yoast_schema)
                    ? $this->convert_yoast_schema_to_metasync($yoast_schema, $post_id)
                    : ['types' => []];

                if (!empty($metasync_schema['types'])) {
                    update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
                    $breakdown['imported']++;
                    continue;
                }
            }

            // Bare Premium article type: build a minimal article schema with
            // placeholders, since Yoast Premium generates schema dynamically.
            if ($article_type_row !== null
                && $this->yoast_schema_source_is_supported($article_type_row->meta_key, $article_type_row->meta_value) !== null) {
                $metasync_schema = [
                    'enabled' => true,
                    'types' => [
                        [
                            'type' => 'article',
                            'fields' => [
                                'title_override' => '{{post_title}}',
                                'description_override' => '{{post_description}}',
                                'image_override' => '{{featured_image}}',
                                'organization_name' => '',
                                'organization_logo' => ''
                            ]
                        ]
                    ]
                ];

                update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
                $breakdown['imported']++;
                continue;
            }

            $breakdown['unsupported']++;
        }

        $breakdown['total_candidates'] = count($rows_by_post);

        return $breakdown;
    }

    /**
     * Import per-post schema from Rank Math
     *
     * Rows are grouped per post, mirroring check_schema_data(): a post with
     * several `rank_math_schema_*` rows is a single candidate, imported from
     * the first row that carries a supported, populated schema.
     *
     * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
     */
    private function import_rankmath_schema()
    {
        $breakdown = $this->empty_schema_breakdown();

        $rows_by_post = [];
        foreach ($this->rankmath_schema_candidate_rows() as $row) {
            $rows_by_post[(int) $row->post_id][] = $row;
        }

        foreach ($rows_by_post as $post_id => $rows) {
            $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
            if ($this->post_has_metasync_schema($post_id)) {
                $breakdown['already_imported']++;
                continue;
            }

            $imported = false;

            foreach ($rows as $row) {
                if ($this->rankmath_schema_source_is_supported($row->meta_key, $row->meta_value) === null) {
                    continue;
                }

                // Extract schema type from meta key (e.g., rank_math_schema_BlogPosting -> BlogPosting)
                $schema_type = str_replace('rank_math_schema_', '', $row->meta_key);

                // Decode Rank Math schema
                $rm_schema = maybe_unserialize($row->meta_value);
                if (empty($rm_schema) || !is_array($rm_schema)) {
                    continue;
                }

                // Convert Rank Math schema to Metasync format
                $metasync_schema = $this->convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id);

                if (!empty($metasync_schema['types'])) {
                    update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
                    $breakdown['imported']++;
                    $imported = true;
                    break; // One schema block per post.
                }
            }

            if (!$imported) {
                $breakdown['unsupported']++;
            }
        }

        $breakdown['total_candidates'] = count($rows_by_post);

        return $breakdown;
    }

    /**
     * Import per-post schema from AIOSEO
     *
     * `schema_type = 'default'` rows and rows whose schema options are empty
     * never reach this loop — they are excluded by aioseo_schema_candidate_rows()
     * for the detector and the importer alike, so the two can never disagree
     * about what counts as a candidate.
     *
     * @return array{imported:int, already_imported:int, unsupported:int, total_candidates:int}
     */
    private function import_aioseo_schema()
    {
        $breakdown = $this->empty_schema_breakdown();

        // An absent AIOSEO table means there is nothing to import at all.
        // Rows are keyed per post so the count matches check_schema_data(),
        // which also counts distinct posts.
        $rows_by_post = [];
        foreach ($this->aioseo_schema_candidate_rows() as $aioseo_data) {
            $rows_by_post[(int) $aioseo_data->post_id] = $aioseo_data;
        }

        foreach ($rows_by_post as $post_id => $aioseo_data) {
            $existing_schema = get_post_meta($post_id, 'metasync_schema_markup', true);
            if ($this->post_has_metasync_schema($post_id)) {
                $breakdown['already_imported']++;
                continue;
            }

            if ($this->aioseo_schema_source_is_supported($aioseo_data->schema_type, $aioseo_data->schema_type_options) === null) {
                $breakdown['unsupported']++;
                continue;
            }

            // Decode AIOSEO schema options
            $schema_options = json_decode((string)($aioseo_data->schema_type_options ?? ''), true);
            if (!is_array($schema_options)) {
                $schema_options = [];
            }

            // Convert AIOSEO schema to Metasync format
            $metasync_schema = $this->convert_aioseo_schema_to_metasync(
                $aioseo_data->schema_type,
                $schema_options,
                $post_id
            );

            if (!empty($metasync_schema['types'])) {
                update_post_meta($post_id, 'metasync_schema_markup', wp_slash(array_merge(is_array($existing_schema) ? $existing_schema : [], $metasync_schema)));
                $breakdown['imported']++;
            } else {
                $breakdown['unsupported']++;
            }
        }

        $breakdown['total_candidates'] = count($rows_by_post);

        return $breakdown;
    }

    /**
     * Convert Yoast schema to Metasync format
     *
     * Handles both storage shapes Yoast has used: the current `@graph`
     * wrapper and the legacy root array of entries. Both are iterated through
     * yoast_schema_graph_items(), which is also what the eligibility check
     * walks — so a payload the detector counts is a payload the importer can
     * convert.
     */
    private function convert_yoast_schema_to_metasync($yoast_schema, $post_id)
    {
        $metasync_schema = [
            'enabled' => true,
            'types' => []
        ];

        // Yoast stores schema as a graph array (legacy installs: a root array
        // of entries, or a single root object, instead of an @graph wrapper).
        $graph_items = $this->yoast_schema_graph_items($yoast_schema);
        if ($graph_items !== []) {
            foreach ($graph_items as $item) {
                if (!is_array($item) || !isset($item['@type'])) {
                    continue;
                }

                $raw_type = $item['@type'];
                if ( is_array( $raw_type ) ) {
                    $raw_type = reset( $raw_type );
                }
                if ( ! is_string( $raw_type ) || '' === $raw_type ) {
                    continue;
                }
                $type = strtolower( $raw_type );

                // Map Yoast types to Metasync types
                if ($type === 'article' || $type === 'newsarticle' || $type === 'blogposting') {
                    $metasync_schema['types'][] = [
                        'type' => 'article',
                        'fields' => [
                            'title_override' => isset($item['headline']) ? $item['headline'] : '{{post_title}}',
                            'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
                            'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
                            'organization_name' => isset($item['publisher']['name']) ? $item['publisher']['name'] : '',
                            'organization_logo' => isset($item['publisher']['logo']['url']) ? $item['publisher']['logo']['url'] : ''
                        ]
                    ];
                } elseif ($type === 'faqpage') {
                    $faq_items = [];
                    if (isset($item['mainEntity']) && is_array($item['mainEntity'])) {
                        foreach ($item['mainEntity'] as $question) {
                            if (isset($question['name']) && isset($question['acceptedAnswer']['text'])) {
                                $faq_items[] = [
                                    'question' => $question['name'],
                                    'answer' => $question['acceptedAnswer']['text']
                                ];
                            }
                        }
                    }
                    if (!empty($faq_items)) {
                        $metasync_schema['types'][] = [
                            'type' => 'FAQPage',
                            'fields' => [
                                'faq_items' => $faq_items
                            ]
                        ];
                    }
                } elseif ($type === 'product') {
                    $metasync_schema['types'][] = [
                        'type' => 'product',
                        'fields' => [
                            'title_override' => isset($item['name']) ? $item['name'] : '{{post_title}}',
                            'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
                            'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
                            'sku' => isset($item['sku']) ? $item['sku'] : '',
                            'brand' => isset($item['brand']['name']) ? $item['brand']['name'] : '',
                            'price' => isset($item['offers']['price']) ? floatval($item['offers']['price']) : 0,
                            'currency' => isset($item['offers']['priceCurrency']) ? $item['offers']['priceCurrency'] : 'USD',
                            'availability' => isset($item['offers']['availability']) ? basename($item['offers']['availability']) : 'InStock',
                            'condition' => isset($item['offers']['itemCondition']) ? basename($item['offers']['itemCondition']) : 'NewCondition'
                        ]
                    ];
                } elseif ($type === 'recipe') {
                    $ingredients = [];
                    if (isset($item['recipeIngredient']) && is_array($item['recipeIngredient'])) {
                        $ingredients = $item['recipeIngredient'];
                    }

                    $instructions = [];
                    if (isset($item['recipeInstructions']) && is_array($item['recipeInstructions'])) {
                        foreach ($item['recipeInstructions'] as $step) {
                            if (is_string($step)) {
                                $instructions[] = $step;
                            } elseif (isset($step['text'])) {
                                $instructions[] = $step['text'];
                            }
                        }
                    }

                    $metasync_schema['types'][] = [
                        'type' => 'recipe',
                        'fields' => [
                            'title_override' => isset($item['name']) ? $item['name'] : '{{post_title}}',
                            'description_override' => isset($item['description']) ? $item['description'] : '{{post_description}}',
                            'image_override' => isset($item['image']) ? (is_array($item['image']) ? $item['image'][0] : $item['image']) : '{{featured_image}}',
                            'yield' => isset($item['recipeYield']) ? $item['recipeYield'] : '',
                            'ingredients' => $ingredients,
                            'instructions' => $instructions,
                            'prep_time' => isset($item['prepTime']) ? $this->parse_duration($item['prepTime']) : 0,
                            'cook_time' => isset($item['cookTime']) ? $this->parse_duration($item['cookTime']) : 0,
                            'total_time' => isset($item['totalTime']) ? $this->parse_duration($item['totalTime']) : 0,
                            'calories' => isset($item['nutrition']['calories']) ? intval($item['nutrition']['calories']) : 0
                        ]
                    ];
                }
            }
        }

        return $metasync_schema;
    }

    /**
     * Convert Rank Math schema to Metasync format
     */
    private function convert_rankmath_schema_to_metasync($rm_schema, $schema_type, $post_id)
    {
        $metasync_schema = [
            'enabled' => true,
            'types' => []
        ];

        $type = strtolower($schema_type);

        // Handle article-like schema types (Article, BlogPosting, NewsArticle, etc.)
        if ($type === 'article' || $type === 'blogposting' || $type === 'newsarticle') {
            $metasync_schema['types'][] = [
                'type' => 'article',
                'fields' => [
                    'title_override' => $this->normalize_text_value($rm_schema['headline'] ?? null, '{{post_title}}'),
                    'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
                    'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
                    'organization_name' => isset($rm_schema['publisher']) ? $rm_schema['publisher'] : '',
                    'organization_logo' => isset($rm_schema['publisher_logo']) ? $rm_schema['publisher_logo'] : ''
                ]
            ];
        } elseif ($type === 'faqpage') {
            $faq_items = [];
            if (isset($rm_schema['questions']) && is_array($rm_schema['questions'])) {
                foreach ($rm_schema['questions'] as $question) {
                    if (isset($question['name']) && isset($question['text'])) {
                        $faq_items[] = [
                            'question' => $question['name'],
                            'answer' => $question['text']
                        ];
                    }
                }
            }
            if (!empty($faq_items)) {
                $metasync_schema['types'][] = [
                    'type' => 'FAQPage',
                    'fields' => [
                        'faq_items' => $faq_items
                    ]
                ];
            }
        } elseif ($type === 'product') {
            $metasync_schema['types'][] = [
                'type' => 'product',
                'fields' => [
                    'title_override' => $this->normalize_text_value($rm_schema['name'] ?? null, '{{post_title}}'),
                    'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
                    'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
                    'sku' => isset($rm_schema['sku']) ? $rm_schema['sku'] : '',
                    'brand' => isset($rm_schema['brand']) ? $rm_schema['brand'] : '',
                    'price' => isset($rm_schema['price']) ? floatval($rm_schema['price']) : 0,
                    'currency' => isset($rm_schema['currency']) ? $rm_schema['currency'] : 'USD',
                    'availability' => isset($rm_schema['inStock']) ? ($rm_schema['inStock'] ? 'InStock' : 'OutOfStock') : 'InStock',
                    'condition' => 'NewCondition'
                ]
            ];
        } elseif ($type === 'recipe') {
            $metasync_schema['types'][] = [
                'type' => 'recipe',
                'fields' => [
                    'title_override' => $this->normalize_text_value($rm_schema['name'] ?? null, '{{post_title}}'),
                    'description_override' => $this->normalize_text_value($rm_schema['description'] ?? null, '{{post_description}}'),
                    'image_override' => $this->normalize_image_value($rm_schema['image'] ?? null),
                    'yield' => isset($rm_schema['recipeYield']) ? $rm_schema['recipeYield'] : '',
                    'ingredients' => isset($rm_schema['recipeIngredient']) ? $rm_schema['recipeIngredient'] : [],
                    'instructions' => isset($rm_schema['recipeInstructions']) ? $rm_schema['recipeInstructions'] : [],
                    'prep_time' => isset($rm_schema['prepTime']) ? intval($rm_schema['prepTime']) : 0,
                    'cook_time' => isset($rm_schema['cookTime']) ? intval($rm_schema['cookTime']) : 0,
                    'total_time' => isset($rm_schema['totalTime']) ? intval($rm_schema['totalTime']) : 0,
                    'calories' => isset($rm_schema['calories']) ? intval($rm_schema['calories']) : 0
                ]
            ];
        }

        return $metasync_schema;
    }

    /**
     * Convert AIOSEO schema to Metasync format
     */
    private function convert_aioseo_schema_to_metasync($schema_type, $schema_options, $post_id)
    {
        $metasync_schema = [
            'enabled' => true,
            'types' => []
        ];

        // Normalise through the shared map so the article family
        // (NewsArticle, BlogPosting) imports the same way the Yoast and Rank
        // Math converters handle it — eligibility resolves them to 'article'
        // too, and the two must agree on every type they let through.
        $type = $this->supported_schema_type($schema_type);

        if ($type === 'article') {
            $metasync_schema['types'][] = [
                'type' => 'article',
                'fields' => [
                    'title_override' => isset($schema_options['headline']) ? $schema_options['headline'] : '{{post_title}}',
                    'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
                    'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
                    'organization_name' => isset($schema_options['organizationName']) ? $schema_options['organizationName'] : '',
                    'organization_logo' => isset($schema_options['organizationLogo']) ? $schema_options['organizationLogo'] : ''
                ]
            ];
        } elseif ($type === 'FAQPage') {
            $faq_items = [];
            if (isset($schema_options['questions']) && is_array($schema_options['questions'])) {
                foreach ($schema_options['questions'] as $question) {
                    if (isset($question['question']) && isset($question['answer'])) {
                        $faq_items[] = [
                            'question' => $question['question'],
                            'answer' => $question['answer']
                        ];
                    }
                }
            }
            if (!empty($faq_items)) {
                $metasync_schema['types'][] = [
                    'type' => 'FAQPage',
                    'fields' => [
                        'faq_items' => $faq_items
                    ]
                ];
            }
        } elseif ($type === 'product') {
            $metasync_schema['types'][] = [
                'type' => 'product',
                'fields' => [
                    'title_override' => isset($schema_options['name']) ? $schema_options['name'] : '{{post_title}}',
                    'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
                    'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
                    'sku' => isset($schema_options['sku']) ? $schema_options['sku'] : '',
                    'brand' => isset($schema_options['brand']) ? $schema_options['brand'] : '',
                    'price' => isset($schema_options['price']) ? floatval($schema_options['price']) : 0,
                    'currency' => isset($schema_options['currency']) ? $schema_options['currency'] : 'USD',
                    'availability' => isset($schema_options['availability']) ? $schema_options['availability'] : 'InStock',
                    'condition' => isset($schema_options['condition']) ? $schema_options['condition'] : 'NewCondition'
                ]
            ];
        } elseif ($type === 'recipe') {
            $metasync_schema['types'][] = [
                'type' => 'recipe',
                'fields' => [
                    'title_override' => isset($schema_options['name']) ? $schema_options['name'] : '{{post_title}}',
                    'description_override' => isset($schema_options['description']) ? $schema_options['description'] : '{{post_description}}',
                    'image_override' => isset($schema_options['image']) ? $schema_options['image'] : '{{featured_image}}',
                    'yield' => isset($schema_options['recipeYield']) ? $schema_options['recipeYield'] : '',
                    'ingredients' => isset($schema_options['recipeIngredient']) ? $schema_options['recipeIngredient'] : [],
                    'instructions' => isset($schema_options['recipeInstructions']) ? $schema_options['recipeInstructions'] : [],
                    'prep_time' => isset($schema_options['prepTime']) ? intval($schema_options['prepTime']) : 0,
                    'cook_time' => isset($schema_options['cookTime']) ? intval($schema_options['cookTime']) : 0,
                    'total_time' => isset($schema_options['totalTime']) ? intval($schema_options['totalTime']) : 0,
                    'calories' => isset($schema_options['calories']) ? intval($schema_options['calories']) : 0
                ]
            ];
        }

        return $metasync_schema;
    }

    /**
     * Parse ISO 8601 duration to minutes
     * e.g., "PT15M" = 15 minutes, "PT1H30M" = 90 minutes
     */
    private function parse_duration($duration)
    {
        if (empty($duration)) {
            return 0;
        }

        // Simple parser for PT format
        $minutes = 0;
        if (preg_match('/PT(\d+)H/', $duration, $hours)) {
            $minutes += intval($hours[1]) * 60;
        }
        if (preg_match('/(\d+)M/', $duration, $mins)) {
            $minutes += intval($mins[1]);
        }

        return $minutes;
    }

    /**
     * Normalize image value to string URL
     * Handles arrays from Rank Math/Yoast and converts placeholders
     */
    private function normalize_image_value($image)
    {
        if (empty($image)) {
            return '{{featured_image}}';
        }

        // If it's an array (from Rank Math/Yoast), extract the URL
        if (is_array($image)) {
            // Check for 'url' key first
            if (isset($image['url'])) {
                $image = $image['url'];
            }
            // Check for '@id' key (Yoast format)
            elseif (isset($image['@id'])) {
                $image = $image['@id'];
            }
            // If it's still an array, try to get first element
            elseif (isset($image[0])) {
                $image = is_string($image[0]) ? $image[0] : '{{featured_image}}';
            }
            else {
                $image = '{{featured_image}}';
            }
        }

        // Convert common placeholder formats to Metasync format
        $placeholder_map = [
            '%post_thumbnail%' => '{{featured_image}}',
            '%featured_image%' => '{{featured_image}}',
            '%seo_title%' => '{{post_title}}',
            '%post_title%' => '{{post_title}}',
            '%seo_description%' => '{{post_description}}',
            '%post_excerpt%' => '{{post_description}}'
        ];

        foreach ($placeholder_map as $old => $new) {
            if ($image === $old || strpos($image, $old) !== false) {
                $image = str_replace($old, $new, $image);
            }
        }

        return is_string($image) ? $image : '{{featured_image}}';
    }

    /**
     * Normalize text value to string
     * Converts placeholders to Metasync format
     */
    private function normalize_text_value($text, $default = '')
    {
        if (empty($text)) {
            return $default;
        }

        // Convert common placeholder formats to Metasync format
        $placeholder_map = [
            '%seo_title%' => '{{post_title}}',
            '%post_title%' => '{{post_title}}',
            '%seo_description%' => '{{post_description}}',
            '%post_excerpt%' => '{{post_description}}'
        ];

        foreach ($placeholder_map as $old => $new) {
            if (is_string($text) && (strpos($text, $old) !== false || $text === $old)) {
                $text = str_replace($old, $new, $text);
            }
        }

        return is_string($text) ? $text : $default;
    }

    /**
     * Tokens whose value is the post's own body or excerpt.
     *
     * Keyed by source plugin. These are the only tokens that can carry the text
     * a password withholds; everything else (title, author, dates, taxonomy) is
     * already public on a protected post.
     *
     * @param string $plugin 'yoast', 'rankmath', or 'aioseo'.
     * @return string[]
     */
    private function content_derived_tokens($plugin) {
        switch ($plugin) {
            case 'yoast':
                return ['%%excerpt%%', '%%excerpt_only%%'];

            case 'rankmath':
                return ['%excerpt%', '%excerpt_only%'];

            case 'aioseo':
                return [
                    '#post_content',
                    '#post_excerpt',
                    '#post_excerpt_only',
                    '#attachment_description',
                    '#attachment_caption',
                ];
        }

        return [];
    }

    /**
     * Whether a raw template would publish text that a post password withholds.
     *
     * WordPress keeps a protected post's body and excerpt behind the password
     * but leaves the post itself published, so an import walks straight past the
     * usual visibility checks: the source plugin stores a template, we resolve it
     * against the raw row, and the body ends up in a meta description that every
     * visitor and crawler can read without ever entering the password.
     *
     * @param string  $text   Raw value from the source plugin.
     * @param WP_Post $post   Post the value belongs to.
     * @param string  $plugin Source plugin.
     * @return bool
     */
    private function references_protected_content($text, $post, $plugin) {
        if (empty($post->post_password)) {
            return false;
        }

        foreach ($this->content_derived_tokens($plugin) as $token) {
            if (strpos($text, $token) !== false) {
                return true;
            }
        }

        return false;
    }

    /**
     * Resolve SEO placeholder tokens to their actual values.
     *
     * Tries each source plugin's own replacement engine first so the result
     * matches exactly what Yoast / Rank Math / AIOSEO would render. Falls back
     * to manual replacement of the most common tokens.
     *
     * @param string $text    Raw string that may contain placeholder tokens.
     * @param int    $post_id Post whose context is used for replacement.
     * @param string $plugin  'yoast', 'rankmath', or 'aioseo'.
     * @return string         Resolved string.
     */
    private function resolve_seo_placeholders($text, $post_id, $plugin) {
        if (empty($text) || !is_string($text)) {
            return (string) $text;
        }

        $post = get_post($post_id);
        if (!$post) {
            return $text;
        }

        // Drop the whole value rather than resolve the content token to nothing
        // and keep the rest. A half-template ("Title |") is worse than no import:
        // it still occupies the meta key, and it still outranks whatever OTTO
        // would have recommended for a page we know nothing publishable about.
        // Callers skip empty values, so nothing is persisted.
        if ($this->references_protected_content($text, $post, $plugin)) {
            return '';
        }

        // Yoast SEO — %%var%% tokens
        if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
            try {
                $replacer = new WPSEO_Replace_Vars();
                $resolved = $replacer->replace($text, $post);
                if (is_string($resolved) && $resolved !== '') {
                    return $resolved;
                }
            } catch (Exception $e) {
                // fall through to manual replacement
            }
        }

        // Rank Math — %var% tokens
        if ($plugin === 'rankmath' && function_exists('rank_math_replace_vars')) {
            try {
                $resolved = rank_math_replace_vars($text, $post);
                if (is_string($resolved) && $resolved !== '') {
                    return $resolved;
                }
            } catch (Exception $e) {
                // fall through to manual replacement
            }
        }

        // All in One SEO — #var tokens
        if ($plugin === 'aioseo') {
            try {
                if (function_exists('aioseo') && isset(aioseo()->tags) && method_exists(aioseo()->tags, 'replaceTags')) {
                    $resolved = aioseo()->tags->replaceTags($text, $post_id);
                    if (is_string($resolved) && $resolved !== '') {
                        return $resolved;
                    }
                }
            } catch (Exception $e) {
                // fall through to manual replacement
            }
        }

        return $this->manually_replace_seo_placeholders($text, $post_id, $plugin);
    }

    /**
     * Manual fallback: replace the most common SEO placeholder tokens from
     * Yoast (%%var%%), Rank Math (%var%), and AIOSEO (#var) formats.
     *
     * AIOSEO tags are handled by resolve_aioseo_placeholders() because they
     * need pattern matching rather than plain string replacement — see the
     * docblock there.
     *
     * @param string $text    Text that may contain placeholder tokens.
     * @param int    $post_id Post ID used for context.
     * @param string $plugin  Optional source plugin ('yoast', 'rankmath',
     *                        'aioseo') so the separator is read from the
     *                        plugin the data actually came from.
     * @return string         Text with tokens replaced.
     */
    private function manually_replace_seo_placeholders($text, $post_id, $plugin = '') {
        $post = get_post($post_id);
        if (!$post) {
            return $text;
        }

        $site_name        = get_bloginfo('name');
        $post_title       = get_the_title($post_id);
        $post_excerpt     = has_excerpt($post_id) ? wp_strip_all_tags(get_the_excerpt($post)) : '';
        $author           = get_the_author_meta('display_name', (int) $post->post_author);
        $date             = get_the_date('', $post_id);
        $modified         = get_the_modified_date('', $post_id);

        $categories       = get_the_category($post_id);
        $primary_category = !empty($categories) ? $categories[0]->name : '';

        $tags             = get_the_tags($post_id);
        $first_tag        = !empty($tags) ? $tags[0]->name : '';

        $sep = $this->resolve_source_separator($plugin);

        $map = [
            // ── Yoast (%%var%%) ──────────────────────────────────────────────
            '%%title%%'            => $post_title,
            '%%sitename%%'         => $site_name,
            '%%sep%%'              => $sep,
            '%%excerpt%%'          => $post_excerpt,
            '%%excerpt_only%%'     => $post_excerpt,
            '%%author%%'           => $author,
            '%%date%%'             => $date,
            '%%modified%%'         => $modified,
            '%%id%%'               => (string) $post_id,
            '%%page%%'             => '',
            '%%pagenumber%%'       => '',
            '%%pagetotal%%'        => '',
            '%%primary_category%%' => $primary_category,
            '%%category%%'         => $primary_category,
            '%%tag%%'              => $first_tag,
            '%%focuskw%%'          => (string) get_post_meta($post_id, '_yoast_wpseo_focuskw', true),
            // ── Rank Math (%var%) ────────────────────────────────────────────
            '%title%'              => $post_title,
            '%sitename%'           => $site_name,
            '%sep%'                => $sep,
            '%excerpt%'            => $post_excerpt,
            '%author%'             => $author,
            '%date%'               => $date,
            '%modified%'           => $modified,
            '%id%'                 => (string) $post_id,
            '%page%'               => '',
            '%category%'           => $primary_category,
            '%tag%'                => $first_tag,
            '%focus_keyword%'      => (string) get_post_meta($post_id, 'rank_math_focus_keyword', true),
        ];

        // Sort longest token first to avoid partial matches
        // e.g. %%primary_category%% must replace before %%category%%
        uksort($map, function ($a, $b) {
            return strlen($b) - strlen($a);
        });

        $text = str_replace(array_keys($map), array_values($map), $text);

        // ── AIOSEO (#var) ────────────────────────────────────────────────────
        // Only for AIOSEO data, or when the caller did not name a source. Yoast
        // and Rank Math leave a literal '#categories' alone, so running this on
        // their imports would rewrite text they would have preserved.
        if ($plugin === 'aioseo' || $plugin === '') {
            $text = $this->resolve_aioseo_placeholders($text, $post_id, $sep);
        }

        return $text;
    }

    /**
     * Resolve the title separator for the plugin the data came from.
     *
     * Each plugin stores its separator in its own option, so an AIOSEO import
     * must not inherit Rank Math's separator just because Rank Math happens to
     * still be active on the site. When the source is known we read only that
     * plugin's setting and otherwise fall back to '-', which is the default
     * every one of the three ships anyway.
     *
     * With no source named, keep the original precedence — whichever plugin is
     * active, Yoast first — so callers that predate the $plugin argument see no
     * behaviour change.
     *
     * @param string $plugin Source plugin ('yoast', 'rankmath', 'aioseo') or ''.
     * @return string        Separator glyph, defaulting to '-'.
     */
    private function resolve_source_separator($plugin = '') {
        switch ($plugin) {
            case 'yoast':
                $sep = $this->get_yoast_separator();
                return $sep !== '' ? $sep : '-';
            case 'rankmath':
                $sep = $this->get_rankmath_separator();
                return $sep !== '' ? $sep : '-';
            case 'aioseo':
                $sep = $this->get_aioseo_separator();
                return $sep !== '' ? $sep : '-';
        }

        // Source unknown: original active-plugin precedence, including the
        // class_exists() guard the old code used, so a site with WPSEO_VERSION
        // defined but Yoast's classes unloaded still falls through to Rank Math
        // exactly as before.
        if (defined('WPSEO_VERSION') && class_exists('WPSEO_Options')) {
            $sep = $this->get_yoast_separator();
            return $sep !== '' ? $sep : '-';
        }
        if (defined('RANK_MATH_VERSION')) {
            $sep = $this->get_rankmath_separator();
            return $sep !== '' ? $sep : '-';
        }

        return '-';
    }

    /**
     * Read Yoast's separator. Yoast stores separator keys like 'sc-dash';
     * convert to a glyph via the public get_separator_options() lookup table.
     *
     * @return string Separator glyph, or '' when unavailable.
     */
    private function get_yoast_separator() {
        if (!defined('WPSEO_VERSION') || !class_exists('WPSEO_Options') || !class_exists('WPSEO_Option_Titles')) {
            return '';
        }

        try {
            $raw     = WPSEO_Options::get('separator', 'sc-dash');
            $options = WPSEO_Option_Titles::get_instance()->get_separator_options();
            if (isset($options[$raw])) {
                return html_entity_decode($options[$raw], ENT_QUOTES, 'UTF-8');
            }
        } catch (Exception $e) {
            return '';
        }

        return '';
    }

    /**
     * Read Rank Math's separator from its general settings option.
     *
     * @return string Separator glyph, or '' when unavailable.
     */
    private function get_rankmath_separator() {
        if (!defined('RANK_MATH_VERSION')) {
            return '';
        }

        $settings = get_option('rank_math_general_settings', []);
        if (is_array($settings) && !empty($settings['title_separator'])) {
            return html_entity_decode($settings['title_separator'], ENT_QUOTES, 'UTF-8');
        }

        return '';
    }

    /**
     * Read AIOSEO's separator from aioseo_options.
     *
     * Lives at searchAppearance.global.separator (NOT breadcrumbs.separator)
     * and is stored HTML-entity encoded, e.g. '&#45;'. The option itself may
     * be a JSON string or an already-decoded array depending on how it was
     * written, so handle both. Read straight from the option rather than
     * through aioseo() so it still works once AIOSEO is deactivated.
     *
     * @return string Separator glyph, or '' when unavailable.
     */
    private function get_aioseo_separator() {
        $options = get_option('aioseo_options', '');
        if (is_string($options) && $options !== '') {
            $options = json_decode($options, true);
        }
        if (!is_array($options)) {
            return '';
        }

        $raw = $options['searchAppearance']['global']['separator'] ?? '';
        if (!is_string($raw) || $raw === '') {
            return '';
        }

        return html_entity_decode($raw, ENT_QUOTES, 'UTF-8');
    }

    /**
     * AIOSEO's site-wide title / meta description templates, per post type.
     *
     * A post with no per-post override still gets a title and description from
     * these — AIOSEO's own getPostDescription() falls back to the post type
     * template before anything else. Reading only the per-post columns therefore
     * imports nothing at all on a site that never overrode individual posts,
     * which is the common case: templates are the default, overrides are the
     * exception.
     *
     * Stored in the 'aioseo_options_dynamic' option, which may be a JSON string
     * or an already-decoded array depending on how it was written — same as
     * get_aioseo_separator() above, and read straight from the option so it keeps
     * working once AIOSEO is deactivated.
     *
     * A post type is included only when AIOSEO is set to show SEO for it AND
     * WordPress registers it as public. The public test is deliberately
     * $obj->public rather than is_post_type_viewable(): a logging CPT registered
     * public=false, publicly_queryable=true is "viewable" by that helper, and
     * would drag hundreds of internal records into the import.
     *
     * @return array<string,array{title:string,description:string}> Keyed by post type.
     */
    private function get_aioseo_post_type_templates() {
        $options = get_option('aioseo_options_dynamic', '');
        if (is_string($options) && $options !== '') {
            $options = json_decode($options, true);
        }
        if (!is_array($options)) {
            return [];
        }

        $post_types = $options['searchAppearance']['postTypes'] ?? [];
        if (!is_array($post_types)) {
            return [];
        }

        $templates = [];

        foreach ($post_types as $post_type => $config) {
            if (!is_array($config)) {
                continue;
            }

            // 'show' false means AIOSEO emits no SEO for the post type at all.
            if (isset($config['show']) && !$config['show']) {
                continue;
            }

            $object = get_post_type_object($post_type);
            if (!$object || empty($object->public)) {
                continue;
            }

            $title       = isset($config['title']) && is_string($config['title']) ? $config['title'] : '';
            $description = isset($config['metaDescription']) && is_string($config['metaDescription'])
                ? $config['metaDescription']
                : '';

            if ($title === '' && $description === '') {
                continue;
            }

            $templates[$post_type] = [
                'title'       => $title,
                'description' => $description,
            ];
        }

        return $templates;
    }

    /**
     * Trim a template-derived meta description to a sane length.
     *
     * AIOSEO does not truncate — its sanitize() only collapses whitespace — so a
     * post type whose template is '#post_content' renders the entire body as the
     * description. Copying that verbatim into post meta would hand every page a
     * multi-thousand-character description, so template-derived values are cut at
     * a word boundary instead. Values the customer typed on the post itself are
     * never passed through here.
     *
     * @param string $text  Resolved description.
     * @param int    $limit Maximum length in characters.
     * @return string       Trimmed description.
     */
    private function truncate_meta_description($text, $limit = 160) {
        $text = trim(preg_replace('/\s+/u', ' ', (string) $text));

        if ($text === '' || mb_strlen($text) <= $limit) {
            return $text;
        }

        // Leave room for the ellipsis so the result never exceeds $limit.
        $clipped = mb_substr($text, 0, $limit - 1);

        // Cut back to the last whole word so the value never ends mid-word.
        $last_space = mb_strrpos($clipped, ' ');
        if ($last_space !== false && $last_space > 0) {
            $clipped = mb_substr($clipped, 0, $last_space);
        }

        $trimmed = rtrim($clipped, " \t\n\r\0\x0B.,;:!?-");

        // Punctuation-only input would otherwise collapse to a bare ellipsis.
        return ($trimmed === '' ? $clipped : $trimmed) . '…';
    }

    /**
     * Every smart-tag id AIOSEO defines, taken from its own Tags class
     * (all-in-one-seo-pack/app/Common/Utils/Tags.php) plus the ids the Pro
     * build adds for WooCommerce.
     *
     * Used for detection as well as replacement: a tag missing from this list
     * is neither resolved nor flagged, so it would be written to post meta as
     * literal text. Restricting detection to AIOSEO's real vocabulary means
     * ordinary text containing a hash — "#1 Rated Clinic", "#hashtag" — is
     * never mistaken for a tag, and tokens AIOSEO does not define (#category_title,
     * #tag_title) stay literal exactly as AIOSEO itself renders them.
     *
     * Supersets are safe in any order thanks to the negative lookahead used
     * for matching: #post_date cannot consume the start of #post_date_w3c,
     * nor #featured_image the start of #featured_image_url.
     *
     * @return string[]
     */
    private function get_aioseo_tag_ids() {
        return [
            'alt_tag', 'archive_date', 'archive_title', 'attachment_caption',
            'attachment_description', 'author_bio', 'author_first_name',
            'author_last_name', 'author_link', 'author_link_alt', 'author_name',
            'author_url', 'blog_link', 'blog_title', 'categories', 'category',
            'category_link', 'category_link_alt', 'current_date', 'current_day',
            'current_month', 'current_year', 'custom_field', 'description',
            'event_end_date', 'event_start_date', 'featured_image',
            'featured_image_url', 'page_number', 'parent_title', 'permalink',
            'post_content', 'post_date', 'post_date_w3c', 'post_day',
            'post_excerpt', 'post_excerpt_only', 'post_link', 'post_link_alt',
            'post_modified_date', 'post_modified_date_w3c', 'post_month',
            'post_title', 'post_year', 'search_term', 'separator_sa',
            'site_description', 'site_link', 'site_link_alt', 'site_title',
            'tagline', 'tax_name', 'tax_parent_name', 'taxonomy_description',
            'taxonomy_title', 'woocommerce_brand', 'woocommerce_price',
            'woocommerce_sku',
        ];
    }

    /**
     * Resolve AIOSEO smart tags to their values.
     *
     * AIOSEO denotes a tag with a single leading '#' and no closing delimiter
     * (Tags::$denotationChar), ending the match with a negative lookahead so
     * supersets do not collide — #post_excerpt must not consume the start of
     * #post_excerpt_only, nor #post_link the start of #post_link_alt. We use
     * the same rule here so results match what AIOSEO itself would render,
     * and so replacement order is irrelevant.
     *
     * @param string $text    Text that may contain AIOSEO tags.
     * @param int    $post_id Post ID used for context.
     * @param string $sep     Separator glyph for #separator_sa.
     * @return string         Text with AIOSEO tags replaced.
     */
    private function resolve_aioseo_placeholders($text, $post_id, $sep = '-') {
        if (!is_string($text) || strpos($text, '#') === false) {
            return (string) $text;
        }

        $post = get_post($post_id);
        if (!$post) {
            return $text;
        }

        // Tags that carry an argument have to be handled before the plain ones.
        // The plain pattern's lookahead permits '-', so '#custom_field-price'
        // would otherwise match bare '#custom_field' and leave an orphan
        // '-price' glued to the resolved value. AIOSEO splits these out for the
        // same reason (Tags::replaceTags() skips them, then parseCustomFields()
        // and parseTaxonomyNames() run afterwards).
        $text = $this->resolve_aioseo_parameterised_tags($text, $post_id);

        // Resolve only the tags the text actually references. Building every
        // value up front meant generating an excerpt for every imported post,
        // and excerpt generation runs the whole the_content filter chain — ~8x
        // the cost per post on real (page-builder) content, for a value the
        // template usually never mentions.
        foreach ($this->get_aioseo_tag_ids() as $tag) {
            $pattern = '/#' . preg_quote($tag, '/') . '(?![a-zA-Z0-9_])/i';
            if (!preg_match($pattern, $text)) {
                continue;
            }

            $value = $this->resolve_aioseo_tag($tag, $post_id, $post, $sep);
            if ($value === null) {
                // Not resolvable outside AIOSEO. Leave it in place so
                // has_unresolved_placeholders() can stop the write.
                continue;
            }

            // Escape backslashes first, then '$', so a value containing "$1"
            // is not treated as a backreference by preg_replace().
            $replacement = str_replace(['\\', '$'], ['\\\\', '\\$'], (string) $value);
            $result      = preg_replace($pattern, $replacement, $text);
            if (is_string($result)) {
                $text = $result;
            }
        }

        return $text;
    }

    /**
     * Resolve the two AIOSEO tags that take an argument after a hyphen:
     * '#custom_field-{meta_key}' and '#tax_name-{taxonomy}'.
     *
     * Patterns and fallback order mirror AIOSEO's parseCustomFields() and
     * parseTaxonomyNames(), including the detail that a bare '#custom_field' or
     * '#tax_name' with no argument resolves to an empty string rather than
     * being left in place.
     *
     * @param string $text    Text that may contain parameterised tags.
     * @param int    $post_id Post ID used for context.
     * @return string         Text with parameterised tags replaced.
     */
    private function resolve_aioseo_parameterised_tags($text, $post_id) {
        $text = preg_replace_callback(
            '/#custom_field-([a-zA-Z0-9_-]+)/i',
            function ($m) use ($post_id) {
                return $this->resolve_aioseo_custom_field($m[1], $post_id);
            },
            $text
        );

        $text = preg_replace_callback(
            '/#tax_name-([a-zA-Z0-9_-]+)/i',
            function ($m) use ($post_id) {
                return $this->resolve_aioseo_taxonomy_name($m[1], $post_id);
            },
            $text
        );

        // With no argument AIOSEO drops the token entirely.
        $text = preg_replace('/#custom_field(?![a-zA-Z0-9_-])/i', '', (string) $text);
        $text = preg_replace('/#tax_name(?![a-zA-Z0-9_-])/i', '', (string) $text);

        return (string) $text;
    }

    /**
     * Value of a custom field referenced by '#custom_field-{key}'.
     *
     * ACF is consulted first when present, matching AIOSEO's own order, so a
     * field with both an ACF definition and raw post meta resolves the way the
     * live site rendered it. Non-scalar values (arrays, objects) have no textual
     * form and become an empty string.
     *
     * @param string $field_key Meta key captured from the tag.
     * @param int    $post_id   Post ID.
     * @return string           Field value as text.
     */
    private function resolve_aioseo_custom_field($field_key, $post_id) {
        $value = '';

        if (function_exists('get_field')) {
            $value = get_field($field_key, $post_id);
        }

        if (empty($value)) {
            $value = get_post_meta($post_id, $field_key, true);
        }

        return is_scalar($value) ? wp_strip_all_tags((string) $value) : '';
    }

    /**
     * Term name for the taxonomy referenced by '#tax_name-{taxonomy}'.
     *
     * AIOSEO prefers its own "primary term" for the taxonomy and falls back to
     * the first assigned term. We have no primary-term store to read once
     * AIOSEO is gone, so the first assigned term is used directly.
     *
     * @param string $taxonomy Taxonomy slug captured from the tag.
     * @param int    $post_id  Post ID.
     * @return string          Term name, or '' when unavailable.
     */
    private function resolve_aioseo_taxonomy_name($taxonomy, $post_id) {
        if (!taxonomy_exists($taxonomy)) {
            return '';
        }

        $terms = get_the_terms($post_id, $taxonomy);
        if (empty($terms) || is_wp_error($terms)) {
            return '';
        }

        $term = reset($terms);

        return (string) $term->name;
    }

    /**
     * Value for a single AIOSEO tag in the context of one post.
     *
     * Every tag AIOSEO can render for a post is resolved here. The two that take
     * an argument (#custom_field, #tax_name) are handled before this switch and
     * so return null, which callers treat as "leave in place" — a leftover then
     * gets reported and the write skipped rather than saved half-resolved.
     *
     * @param string  $tag     AIOSEO tag id, without the leading '#'.
     * @param int     $post_id Post ID.
     * @param WP_Post $post    Post object.
     * @param string  $sep     Separator glyph.
     * @return string|null     Replacement value, or null if unresolvable.
     */
    private function resolve_aioseo_tag($tag, $post_id, $post, $sep) {
        // Second line of defence behind the whole-value check in
        // resolve_seo_placeholders(). Any future caller that reaches a single
        // tag directly still cannot read out a protected post's body.
        if (!empty($post->post_password)
            && in_array('#' . $tag, $this->content_derived_tokens('aioseo'), true)) {
            return '';
        }

        switch ($tag) {
            case 'site_title':
            case 'blog_title':
                return (string) get_bloginfo('name');

            case 'tagline':
            case 'site_description':
                return (string) get_bloginfo('description');

            case 'post_title':
                return (string) get_the_title($post_id);

            case 'parent_title':
                $parent = isset($post->post_parent) ? (int) $post->post_parent : 0;
                return $parent ? (string) get_the_title($parent) : '';

            case 'post_excerpt_only':
                // Manual excerpt only — AIOSEO never falls back for this one.
                return isset($post->post_excerpt)
                    ? $this->strip_builder_markup((string) $post->post_excerpt)
                    : '';

            case 'post_excerpt':
                // Manual excerpt if set, otherwise a content-derived one, which
                // is what AIOSEO does. This is the expensive branch, so it only
                // runs when the template actually references the tag.
                $manual = isset($post->post_excerpt) ? (string) $post->post_excerpt : '';
                return $this->strip_builder_markup(
                    $manual !== '' ? $manual : (string) get_the_excerpt($post)
                );

            case 'post_content':
                return $this->strip_builder_markup((string) $post->post_content);

            case 'separator_sa':
                return (string) $sep;

            case 'author_name':
                return (string) get_the_author_meta('display_name', (int) $post->post_author);
            case 'author_first_name':
                return (string) get_the_author_meta('first_name', (int) $post->post_author);
            case 'author_last_name':
                return (string) get_the_author_meta('last_name', (int) $post->post_author);
            case 'author_bio':
                return (string) get_the_author_meta('description', (int) $post->post_author);

            case 'author_link':
            case 'author_link_alt':
            case 'author_url':
                $author = isset($post->post_author) ? (int) $post->post_author : 0;
                return $author ? (string) get_author_posts_url($author) : '';

            case 'current_date':
                $format = get_option('date_format');
                $format = (is_string($format) && $format !== '') ? $format : 'F j, Y';
                return (string) date_i18n($format);
            case 'current_day':
                return (string) date_i18n('d');
            case 'current_month':
                return (string) date_i18n('F');
            case 'current_year':
                return (string) date_i18n('Y');

            case 'post_date':
                return (string) get_the_date('', $post_id);
            case 'post_day':
                return (string) get_the_date('d', $post_id);
            case 'post_month':
                return (string) get_the_date('F', $post_id);
            case 'post_year':
                return (string) get_the_date('Y', $post_id);

            case 'permalink':
            case 'post_link':
            case 'post_link_alt':
                return (string) get_permalink($post_id);

            case 'site_link':
            case 'site_link_alt':
            case 'blog_link':
                return (string) home_url();

            case 'categories':
                $names = [];
                foreach ($this->get_post_categories($post_id) as $category) {
                    if (isset($category->name)) {
                        $names[] = $category->name;
                    }
                }
                return implode(', ', $names);

            case 'category':
            case 'taxonomy_title':
                $categories = $this->get_post_categories($post_id);
                return !empty($categories) && isset($categories[0]->name) ? (string) $categories[0]->name : '';

            case 'taxonomy_description':
                $categories = $this->get_post_categories($post_id);
                return !empty($categories) && isset($categories[0]->description) ? (string) $categories[0]->description : '';

            case 'category_link':
            case 'category_link_alt':
                $categories = $this->get_post_categories($post_id);
                return !empty($categories) && isset($categories[0]->term_id)
                    ? (string) get_category_link($categories[0]->term_id)
                    : '';

            case 'featured_image':
                return (string) get_the_post_thumbnail_url($post_id, 'full');

            case 'featured_image_url':
                $thumbnail_id = get_post_thumbnail_id($post_id);
                if (!$thumbnail_id) {
                    return '';
                }
                $src = wp_get_attachment_image_src($thumbnail_id, 'full');
                return (is_array($src) && !empty($src[0])) ? (string) $src[0] : '';

            case 'alt_tag':
                // AIOSEO reads the alt off the post id itself, not off the
                // featured image's attachment. Mirror that: on an ordinary post
                // this is empty, which is what the live site rendered.
                return (string) get_post_meta($post_id, '_wp_attachment_image_alt', true);

            case 'attachment_caption':
                return (string) wp_get_attachment_caption($post_id);

            case 'attachment_description':
                return $this->strip_builder_markup((string) $post->post_content);

            case 'post_modified_date':
                return (string) get_the_modified_date('', $post_id);

            case 'post_date_w3c':
                return (string) mysql2date(DATE_W3C, $post->post_date, false);

            case 'post_modified_date_w3c':
                return (string) mysql2date(DATE_W3C, $post->post_modified, false);

            case 'woocommerce_sku':
                return (string) get_post_meta($post_id, '_sku', true);

            case 'woocommerce_price':
                return (string) get_post_meta($post_id, '_price', true);

            // AIOSEO renders these empty in a single-post context.
            case 'description':
            case 'page_number':
            case 'search_term':
            case 'archive_date':
            case 'archive_title':
            // Term-context only. AIOSEO resolves #tax_parent_name off a term id,
            // so it is empty for a post.
            case 'tax_parent_name':
            // Supplied by AIOSEO's event add-on, which has no data to read here.
            case 'event_start_date':
            case 'event_end_date':
            case 'woocommerce_brand':
                return '';
        }

        // #custom_field and #tax_name take an argument and are resolved ahead of
        // this switch, in resolve_aioseo_parameterised_tags().
        return null;
    }

    /**
     * Reduce raw post content to plain prose.
     *
     * Page-builder pages are almost entirely shortcode markup, so a description
     * template built on the content imports as '[et_pb_section][et_pb_row]…'
     * without this. AIOSEO strips shortcodes too, but strip_shortcodes() only
     * removes tags that are *registered* at the time it runs, and a builder
     * registers its own only in the contexts it renders in — not necessarily
     * during an import request. So sweep any remaining shortcode-shaped tokens
     * as well, which also covers content left behind by a builder that is no
     * longer installed.
     *
     * @param string $content Raw post content.
     * @return string         Plain text.
     */
    private function strip_builder_markup($content) {
        $content = strip_shortcodes($content);

        // Opening/closing shortcode-shaped tokens: [tag], [tag a="b"], [/tag].
        $content = preg_replace('/\[\/?[a-z][a-z0-9_-]*(?:\s[^\]]*)?\]/i', ' ', (string) $content);

        return trim(preg_replace('/\s+/u', ' ', wp_strip_all_tags((string) $content)));
    }

    /**
     * Categories for a post, normalised to an array.
     *
     * @param int $post_id Post ID.
     * @return array
     */
    private function get_post_categories($post_id) {
        $categories = get_the_category($post_id);
        return is_array($categories) ? $categories : [];
    }

    /**
     * Whether a value still contains AIOSEO tags after resolution.
     *
     * Callers use this to skip the write instead of persisting a literal
     * token such as '#post_title' as a page title. A tag that legitimately
     * resolves to an empty string is gone from the text and so is not
     * reported here.
     *
     * Only AIOSEO is checked — see the fall-through comment at the end for why
     * Yoast and Rank Math are excluded.
     *
     * @param string $text   Resolved text.
     * @param string $plugin Source plugin; only 'aioseo' is checked.
     * @return bool          True when at least one tag survived.
     */
    private function has_unresolved_placeholders($text, $plugin) {
        if (!is_string($text) || $text === '') {
            return false;
        }

        switch ($plugin) {
            case 'aioseo':
                if (strpos($text, '#') === false) {
                    return false;
                }
                foreach ($this->get_aioseo_tag_ids() as $tag) {
                    if (preg_match('/#' . preg_quote($tag, '/') . '(?![a-zA-Z0-9_])/i', $text)) {
                        return true;
                    }
                }
                return false;
        }

        // Yoast and Rank Math deliberately fall through. Their manual fallback
        // maps cover only a fraction of the variables those plugins define
        // (16 of ~60 for Yoast, 12 of 49 for Rank Math), so treating a leftover
        // token as a reason to skip would drop common real-world titles
        // wholesale — "Best Dentist in %currentyear% %sep% %sitename%" resolves
        // only partly and would import as nothing at all. Until those maps are
        // filled in, their existing behaviour (save the partly-resolved value)
        // is the lesser evil and is left untouched.
        return false;
    }

    /**
     * Resolve an imported value and decide whether it is safe to persist.
     *
     * Returns null when a placeholder survived resolution, so the caller skips
     * the write rather than saving a literal '#post_title' that would then
     * render as the page title and outrank OTTO's recommendation. Skipped
     * values are collected in $unresolved so the import report can surface
     * them.
     *
     * @param string $raw        Raw value from the source plugin.
     * @param int    $post_id    Post ID used for context.
     * @param string $plugin     Source plugin ('yoast', 'rankmath', 'aioseo').
     * @param string $meta_key   Destination meta key, for the report.
     * @param array  $unresolved Collected failures, appended to by reference.
     * @return string|null       Resolved value, or null when it must be skipped.
     */
    private function resolve_import_value($raw, $post_id, $plugin, $meta_key, array &$unresolved) {
        $resolved = $this->resolve_seo_placeholders($raw, $post_id, $plugin);

        if ($this->has_unresolved_placeholders($resolved, $plugin)) {
            $unresolved[] = [
                'post_id'  => (int) $post_id,
                'meta_key' => $meta_key,
                'raw'      => (string) $raw,
                'resolved' => (string) $resolved,
            ];

            if (defined('WP_DEBUG') && WP_DEBUG) {
                error_log(sprintf( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- debug-gated, no secrets
                    'MetaSync importer: unresolved %s placeholder for post %d (%s) — %s',
                    $plugin,
                    (int) $post_id,
                    $meta_key,
                    (string) $raw
                ));
            }

            return null;
        }

        return $resolved;
    }

    /**
     * Sanitize an imported value according to what its destination key holds.
     *
     * Kept in one place because the post and term writers must agree: they are
     * two copies of the same rule, and the pair has already had to be patched in
     * lockstep once (image keys were reaching the free-text sanitizer).
     *
     * An image key goes through esc_url_raw(), which returns '' for anything it
     * will not vouch for — a bare word, a javascript: scheme, a mangled host.
     * Callers must treat that '' as "could not sanitize", not as "empty value";
     * the two mean different things to an overwrite run.
     *
     * @param  string $value    Resolved value from the source plugin.
     * @param  string $meta_key Imported destination key.
     * @return string           Sanitized value, or '' if it did not survive.
     */
    private function sanitize_imported_value($value, $meta_key) {
        if ($meta_key === Metasync_Seo_Precedence::KEY_IMPORTED_TITLE) {
            return sanitize_text_field($value);
        }

        if (in_array($meta_key, array(
            Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
            Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
        ), true)) {
            return esc_url_raw($value);
        }

        return sanitize_textarea_field($value);
    }

    /**
     * Persist an imported title or description on its low-priority key.
     *
     * Every importer routes through here so the three source plugins cannot
     * drift apart on the rules that matter: imported values never land on the
     * customer-override keys, a value that resolves to nothing is not written,
     * and an overwrite run clears a now-stale value instead of preserving it.
     *
     * @param int    $post_id    Post being imported.
     * @param string $raw        Raw value from the source plugin.
     * @param string $plugin     'yoast', 'rankmath', or 'aioseo'.
     * @param string $meta_key   Imported destination key.
     * @param bool   $overwrite  Whether the run replaces existing values.
     * @param array  $unresolved Collected failures, appended to by reference.
     * @param bool   $truncate   Trim to meta-description length first.
     * @return bool              Whether the post's meta changed.
     */
    private function store_imported_post_value($post_id, $raw, $plugin, $meta_key, $overwrite, array &$unresolved, $truncate = false) {
        if ($raw === '') {
            return false;
        }

        $existing = get_post_meta($post_id, $meta_key, true);
        if (!empty($existing) && !$overwrite) {
            return false;
        }

        $resolved = $this->resolve_import_value($raw, $post_id, $plugin, $meta_key, $unresolved);

        if ($resolved !== null && $truncate) {
            $resolved = $this->truncate_meta_description($resolved);
        }

        if ($resolved !== null && trim($resolved) !== '') {
            $sanitized = $this->sanitize_imported_value($resolved, $meta_key);

            // The source gave us something, but it could not survive
            // sanitization — an og:image/twitter:image URL rejected by
            // esc_url_raw() is the case this guards. Writing the resulting ''
            // would blank a good stored image and still report the import as a
            // success. Skip instead: no write, no success, and whatever is
            // already stored stays. A genuinely empty source is a different
            // thing and is handled by the overwrite branch below.
            if ($sanitized === '') {
                return false;
            }

            // update_post_meta() unslashes what it is given, so a value holding
            // a backslash ("AC\DC") would be stored stripped ("ACDC"). Slash it
            // here so the round trip through meta storage is lossless.
            update_post_meta($post_id, $meta_key, wp_slash($sanitized));
            return true;
        }

        // Overwrite means "make this match the source". The source now yields
        // nothing, so a value left from an earlier run is stale — clear it so
        // re-importing repairs rather than preserves. Only ever touches the
        // imported key, never anything the customer typed.
        if ($overwrite && !empty($existing)) {
            delete_post_meta($post_id, $meta_key);
            return true;
        }

        return false;
    }

    /**
     * Resolve placeholder tokens in a value imported for a taxonomy term.
     *
     * The post resolver cannot be reused. Every token it answers comes off a
     * WP_Post, and a term has no post to answer from — asking it would either
     * blank the value or, worse, resolve it against whatever post happens to
     * share the term's ID. The vocabularies barely overlap anyway: a term
     * template is built from the term's own name and description, so this is a
     * deliberately small, separate map.
     *
     * @param string  $text   Raw value from the source plugin.
     * @param WP_Term $term   Term the value belongs to.
     * @param string  $plugin 'yoast', 'rankmath', or 'aioseo'.
     * @return string         Resolved string.
     */
    private function resolve_term_placeholders($text, $term, $plugin) {
        if (empty($text) || empty($term->term_id)) {
            return (string) $text;
        }

        // Yoast and Rank Math both accept a term in place of a post, so their
        // own engines still give the exact string the source site rendered.
        if ($plugin === 'yoast' && class_exists('WPSEO_Replace_Vars')) {
            try {
                $replacer = new WPSEO_Replace_Vars();
                $resolved = $replacer->replace($text, $term);
                if (is_string($resolved) && $resolved !== '') {
                    return $resolved;
                }
            } catch (Exception $e) {
                // fall through to manual replacement
            }
        }

        if ($plugin === 'rankmath' && function_exists('rank_math_replace_vars')) {
            try {
                $resolved = rank_math_replace_vars($text, $term);
                if (is_string($resolved) && $resolved !== '') {
                    return $resolved;
                }
            } catch (Exception $e) {
                // fall through to manual replacement
            }
        }

        // AIOSEO's replaceTags() takes a post ID. Handing it a term ID does not
        // fail — it resolves against whatever post shares that ID and returns
        // another page's title, so the native engine is deliberately skipped
        // here and the manual map below is the only path for AIOSEO terms.
        return $this->manually_replace_term_placeholders($text, $term, $plugin);
    }

    /**
     * Manual token replacement for term values.
     *
     * @param string  $text
     * @param WP_Term $term
     * @param string  $plugin
     * @return string
     */
    private function manually_replace_term_placeholders($text, $term, $plugin) {
        $site_name = (string) get_bloginfo('name');
        $site_desc = (string) get_bloginfo('description');
        $sep       = $this->resolve_source_separator($plugin);
        $year      = (string) gmdate('Y');

        $term_name = (string) $term->name;
        $term_desc = wp_strip_all_tags((string) $term->description);

        $map = [
            // ── Yoast (%%var%%) ──────────────────────────────────────────────
            '%%term_title%%'           => $term_name,
            '%%term_description%%'     => $term_desc,
            '%%category%%'             => $term_name,
            '%%category_description%%' => $term_desc,
            '%%tag%%'                  => $term_name,
            '%%sitename%%'             => $site_name,
            '%%sitedesc%%'             => $site_desc,
            '%%sep%%'                  => $sep,
            '%%currentyear%%'          => $year,
            '%%page%%'                 => '',
            '%%pagenumber%%'           => '',
            '%%pagetotal%%'            => '',
            // ── Rank Math (%var%) ────────────────────────────────────────────
            '%term%'                   => $term_name,
            '%term_description%'       => $term_desc,
            '%category%'               => $term_name,
            '%tag%'                    => $term_name,
            '%sitename%'               => $site_name,
            '%sitedesc%'               => $site_desc,
            '%sep%'                    => $sep,
            '%currentyear%'            => $year,
            '%page%'                   => '',
            // ── AIOSEO (#var) ────────────────────────────────────────────────
            '#taxonomy_title'          => $term_name,
            '#taxonomy_description'    => $term_desc,
            '#archive_title'           => $term_name,
            '#site_title'              => $site_name,
            '#blog_title'              => $site_name,
            '#site_description'        => $site_desc,
            '#tagline'                 => $site_desc,
            '#separator_sa'            => $sep,
            '#current_year'            => $year,
            '#page_number'             => '',
        ];

        // Longest token first, so a short token never eats the start of a
        // longer one (#site_title must not consume #site_description).
        uksort($map, function ($a, $b) {
            return strlen($b) - strlen($a);
        });

        $resolved = str_replace(array_keys($map), array_values($map), $text);

        return trim(preg_replace('/\s+/', ' ', $resolved));
    }

    /**
     * Persist an imported term title or description on its low-priority key.
     *
     * Mirrors store_imported_post_value(): imported values never land on the
     * key that means "the customer set this", a value that resolves to nothing
     * is not written, and an overwrite run clears a now-stale value.
     *
     * @param WP_Term $term      Term being imported.
     * @param string  $raw       Raw value from the source plugin.
     * @param string  $plugin    'yoast', 'rankmath', or 'aioseo'.
     * @param string  $meta_key  Imported destination key.
     * @param bool    $overwrite Whether the run replaces existing values.
     * @return bool              Whether the term's meta changed.
     */
    private function store_imported_term_value($term, $raw, $plugin, $meta_key, $overwrite) {
        if ($raw === '') {
            return false;
        }

        $term_id  = (int) $term->term_id;
        $existing = get_term_meta($term_id, $meta_key, true);
        if (!empty($existing) && !$overwrite) {
            return false;
        }

        $resolved = $this->resolve_term_placeholders($raw, $term, $plugin);

        // A token we do not know would otherwise be stored literally and render
        // as '#taxonomy_title' on the archive — the defect this path exists to
        // prevent. Skipping leaves the slot free for OTTO.
        if ($this->has_unresolved_placeholders($resolved, $plugin)) {
            return false;
        }

        if (trim($resolved) !== '') {
            $sanitized = $this->sanitize_imported_value($resolved, $meta_key);

            // See store_imported_post_value(): a value the source supplied but
            // sanitization rejected must not blank an existing one, and must
            // not count as a successful update.
            if ($sanitized === '') {
                return false;
            }

            // update_term_meta() unslashes what it stores; slash first so a
            // backslash in the value survives the round trip.
            update_term_meta($term_id, $meta_key, wp_slash($sanitized));
            return true;
        }

        if ($overwrite && !empty($existing)) {
            delete_term_meta($term_id, $meta_key);
            return true;
        }

        return false;
    }

    /**
     * Add unresolved-placeholder reporting to an import result.
     *
     * @param array $result     Result array to augment.
     * @param array $unresolved Failures collected during the batch.
     * @return array            Result with the report attached.
     */
    private function attach_unresolved_report(array $result, array $unresolved) {
        $count = count($unresolved);
        $result['unresolved'] = $count;

        if ($count === 0) {
            return $result;
        }

        // Keep the payload small — a few examples are enough to diagnose.
        $result['unresolved_samples'] = array_slice($unresolved, 0, 5);

        if (!empty($result['message'])) {
            $result['message'] .= sprintf(
                ' %d value(s) skipped because placeholders could not be resolved.',
                $count
            );
        }

        return $result;
    }

    /**
     * Import SEO Metadata (Titles and Descriptions)
     * Supports batch processing via AJAX
     *
     * @param string $plugin Plugin to import from (yoast, rankmath, aioseo)
     * @param array $options Import options (import_titles, import_descriptions, overwrite_existing, batch_size, offset)
     * @return array Result with success status, progress info, and statistics
     */
    public function import_seo_metadata($plugin, $options = [])
    {
        // Default options
        $defaults = [
            'import_titles' => true,
            'import_descriptions' => true,
            'overwrite_existing' => false,
            'batch_size' => 50, // Process 50 posts per batch
            'offset' => 0
        ];
        $options = array_merge($defaults, $options);

        // Validate plugin
        if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'])) {
            return [
                'success' => false,
                'message' => 'Invalid plugin specified.'
            ];
        }

        // Route to appropriate import method
        switch ($plugin) {
            case 'yoast':
                return $this->import_yoast_seo_metadata($options);
            case 'rankmath':
                return $this->import_rankmath_seo_metadata($options);
            case 'aioseo':
                return $this->import_aioseo_seo_metadata($options);
        }

        return [
            'success' => false,
            'message' => 'Unknown error occurred.'
        ];
    }

    /**
     * Import SEO metadata from Yoast SEO
     */
    private function import_yoast_seo_metadata($options)
    {
        global $wpdb;

        $batch_size = intval($options['batch_size']);
        $offset = intval($options['offset']);
        $import_titles = (bool) $options['import_titles'];
        $import_descriptions = (bool) $options['import_descriptions'];
        $import_social_text = !empty($options['import_social_text']);
        $import_social_images = !empty($options['import_social_images']);
        $overwrite = (bool) $options['overwrite_existing'];

        // Build WHERE clause for meta keys
        $meta_keys = [];
        if ($import_titles) {
            $meta_keys[] = '_yoast_wpseo_title';
        }
        if ($import_descriptions) {
            $meta_keys[] = '_yoast_wpseo_metadesc';
        }
        if ($import_social_text) {
            $meta_keys[] = '_yoast_wpseo_opengraph-title';
            $meta_keys[] = '_yoast_wpseo_opengraph-description';
            $meta_keys[] = '_yoast_wpseo_twitter-title';
            $meta_keys[] = '_yoast_wpseo_twitter-description';
        }
        if ($import_social_images) {
            $meta_keys[] = '_yoast_wpseo_opengraph-image';
            $meta_keys[] = '_yoast_wpseo_twitter-image';
        }

        if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
            return [
                'success' => false,
                'message' => 'No import options selected.'
            ];
        }

        // Get total count (for progress tracking)
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $total_posts = (int) $wpdb->get_var($wpdb->prepare('
            SELECT COUNT(DISTINCT post_id)
            FROM ' . $wpdb->postmeta . '
            WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
        ", $meta_keys));

        // Get batch of posts with Yoast data
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $posts = $wpdb->get_results($wpdb->prepare('
            SELECT DISTINCT post_id
            FROM ' . $wpdb->postmeta . '
            WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            ORDER BY post_id ASC
            LIMIT %d OFFSET %d
        ", array_merge($meta_keys, [$batch_size, $offset])));

        $imported_count = 0;
        $skipped_count = 0;
        $unresolved = [];

        foreach ($posts as $post_obj) {
            $post_id = $post_obj->post_id;
            $updated = false;

            // Import title
            if ($import_titles) {
                $yoast_title = get_post_meta($post_id, '_yoast_wpseo_title', true);
                if (!empty($yoast_title)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $yoast_title,
                        'yoast',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
                        $overwrite,
                        $unresolved
                    );
                }
            }

            // Import description
            if ($import_descriptions) {
                $yoast_desc = get_post_meta($post_id, '_yoast_wpseo_metadesc', true);
                if (!empty($yoast_desc)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $yoast_desc,
                        'yoast',
                        Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }
            }

            // Import social text
            if ($import_social_text) {
                $yoast_og_title = get_post_meta($post_id, '_yoast_wpseo_opengraph-title', true);
                if (!empty($yoast_og_title)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $yoast_og_title,
                        'yoast',
                        Metasync_Seo_Precedence::KEY_IMPORTED_OG_TITLE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $yoast_og_desc = get_post_meta($post_id, '_yoast_wpseo_opengraph-description', true);
                if (!empty($yoast_og_desc)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $yoast_og_desc,
                        'yoast',
                        Metasync_Seo_Precedence::KEY_IMPORTED_OG_DESC,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $yoast_tw_title = get_post_meta($post_id, '_yoast_wpseo_twitter-title', true);
                if (!empty($yoast_tw_title)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $yoast_tw_title,
                        'yoast',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_TITLE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $yoast_tw_desc = get_post_meta($post_id, '_yoast_wpseo_twitter-description', true);
                if (!empty($yoast_tw_desc)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $yoast_tw_desc,
                        'yoast',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_DESC,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }
            }

            // Import social images
            if ($import_social_images) {
                $yoast_og_image = get_post_meta($post_id, '_yoast_wpseo_opengraph-image', true);
                if (!empty($yoast_og_image)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $yoast_og_image,
                        'yoast',
                        Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $yoast_twitter_image = get_post_meta($post_id, '_yoast_wpseo_twitter-image', true);
                if (!empty($yoast_twitter_image)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $yoast_twitter_image,
                        'yoast',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }
            }

            if ($updated) {
                $imported_count++;
            } else {
                $skipped_count++;
            }
        }

        $processed = $offset + count($posts);
        $is_complete = $processed >= $total_posts;

        return $this->attach_unresolved_report([
            'success' => true,
            'imported' => $imported_count,
            'skipped' => $skipped_count,
            'total' => $total_posts,
            'processed' => $processed,
            'is_complete' => $is_complete,
            'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
            'message' => $is_complete
                ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
                : "Processing... {$imported_count} imported, {$skipped_count} skipped."
        ], $unresolved);
    }

    /**
     * Import SEO metadata from Rank Math
     */
    private function import_rankmath_seo_metadata($options)
    {
        global $wpdb;

        $batch_size = intval($options['batch_size']);
        $offset = intval($options['offset']);
        $import_titles = (bool) $options['import_titles'];
        $import_descriptions = (bool) $options['import_descriptions'];
        $import_social_text = !empty($options['import_social_text']);
        $import_social_images = !empty($options['import_social_images']);
        $overwrite = (bool) $options['overwrite_existing'];

        // Build WHERE clause for meta keys
        $meta_keys = [];
        if ($import_titles) {
            $meta_keys[] = 'rank_math_title';
        }
        if ($import_descriptions) {
            $meta_keys[] = 'rank_math_description';
        }
        if ($import_social_text) {
            $meta_keys[] = 'rank_math_facebook_title';
            $meta_keys[] = 'rank_math_facebook_description';
            $meta_keys[] = 'rank_math_twitter_title';
            $meta_keys[] = 'rank_math_twitter_description';
        }
        if ($import_social_images) {
            $meta_keys[] = 'rank_math_facebook_image';
            $meta_keys[] = 'rank_math_twitter_image';
        }

        if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
            return [
                'success' => false,
                'message' => 'No import options selected.'
            ];
        }

        // Get total count
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $total_posts = (int) $wpdb->get_var($wpdb->prepare('
            SELECT COUNT(DISTINCT post_id)
            FROM ' . $wpdb->postmeta . '
            WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
        ", $meta_keys));

        // Get batch of posts
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        $posts = $wpdb->get_results($wpdb->prepare('
            SELECT DISTINCT post_id
            FROM ' . $wpdb->postmeta . '
            WHERE meta_key IN (' . implode(',', array_fill(0, count($meta_keys), '%s')) . ")
            AND meta_value != ''
            AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            ORDER BY post_id ASC
            LIMIT %d OFFSET %d
        ", array_merge($meta_keys, [$batch_size, $offset])));

        $imported_count = 0;
        $skipped_count = 0;
        $unresolved = [];

        foreach ($posts as $post_obj) {
            $post_id = $post_obj->post_id;
            $updated = false;

            // Import title
            if ($import_titles) {
                $rm_title = get_post_meta($post_id, 'rank_math_title', true);
                if (!empty($rm_title)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $rm_title,
                        'rankmath',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
                        $overwrite,
                        $unresolved
                    );
                }
            }

            // Import description
            if ($import_descriptions) {
                $rm_desc = get_post_meta($post_id, 'rank_math_description', true);
                if (!empty($rm_desc)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $rm_desc,
                        'rankmath',
                        Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }
            }

            // Import social text
            if ($import_social_text) {
                $rm_og_title = get_post_meta($post_id, 'rank_math_facebook_title', true);
                if (!empty($rm_og_title)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $rm_og_title,
                        'rankmath',
                        Metasync_Seo_Precedence::KEY_IMPORTED_OG_TITLE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $rm_og_desc = get_post_meta($post_id, 'rank_math_facebook_description', true);
                if (!empty($rm_og_desc)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $rm_og_desc,
                        'rankmath',
                        Metasync_Seo_Precedence::KEY_IMPORTED_OG_DESC,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $rm_tw_title = get_post_meta($post_id, 'rank_math_twitter_title', true);
                if (!empty($rm_tw_title)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $rm_tw_title,
                        'rankmath',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_TITLE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $rm_tw_desc = get_post_meta($post_id, 'rank_math_twitter_description', true);
                if (!empty($rm_tw_desc)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $rm_tw_desc,
                        'rankmath',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_DESC,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }
            }

            // Import social images
            if ($import_social_images) {
                $rm_og_image = get_post_meta($post_id, 'rank_math_facebook_image', true);
                if (!empty($rm_og_image)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $rm_og_image,
                        'rankmath',
                        Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $rm_twitter_image = get_post_meta($post_id, 'rank_math_twitter_image', true);
                if (!empty($rm_twitter_image)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $rm_twitter_image,
                        'rankmath',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }
            }

            if ($updated) {
                $imported_count++;
            } else {
                $skipped_count++;
            }
        }

        $processed = $offset + count($posts);
        $is_complete = $processed >= $total_posts;

        return $this->attach_unresolved_report([
            'success' => true,
            'imported' => $imported_count,
            'skipped' => $skipped_count,
            'total' => $total_posts,
            'processed' => $processed,
            'is_complete' => $is_complete,
            'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
            'message' => $is_complete
                ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
                : "Processing... {$imported_count} imported, {$skipped_count} skipped."
        ], $unresolved);
    }

    /**
     * Import SEO metadata from All in One SEO
     */
    private function import_aioseo_seo_metadata($options)
    {
        global $wpdb;

        $batch_size = intval($options['batch_size']);
        $offset = intval($options['offset']);
        $import_titles = (bool) $options['import_titles'];
        $import_descriptions = (bool) $options['import_descriptions'];
        $import_social_text = !empty($options['import_social_text']);
        $import_social_images = !empty($options['import_social_images']);
        $overwrite = (bool) $options['overwrite_existing'];

        // Check if AIOSEO table exists
        $table = $wpdb->prefix . 'aioseo_posts';
        if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))) !== $table) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
            return [
                'success' => false,
                'message' => 'AIOSEO database table not found.'
            ];
        }

        if (!$import_titles && !$import_descriptions && !$import_social_text && !$import_social_images) {
            return [
                'success' => false,
                'message' => 'No import options selected.'
            ];
        }

        // Columns read off the AIOSEO row, excluding post_id. The same list gates
        // row selection, so deriving both from one array keeps them in step — a
        // WHERE that admitted a column the SELECT did not fetch would read a
        // property that isn't there.
        $value_columns = [];
        if ($import_titles) {
            $value_columns[] = 'title';
        }
        if ($import_descriptions) {
            $value_columns[] = 'description';
        }
        if ($import_social_text) {
            $value_columns[] = 'og_title';
            $value_columns[] = 'og_description';
            $value_columns[] = 'twitter_title';
            $value_columns[] = 'twitter_description';
        }
        if ($import_social_images) {
            $value_columns[] = 'og_image_custom_url';
            $value_columns[] = 'twitter_image_custom_url';
        }

        $build_has_value_clause = function ($prefix) use ($value_columns) {
            $conditions = [];
            foreach ($value_columns as $column) {
                $col          = $prefix . $column;
                $conditions[] = "({$col} IS NOT NULL AND {$col} != '')";
            }
            return implode(' OR ', $conditions);
        };

        $where_clause          = $build_has_value_clause('');
        $joined_has_value      = $build_has_value_clause('a.');

        // AIOSEO serves a title and description for every post through its
        // site-wide templates, not only for posts carrying a per-post override.
        // Walking the AIOSEO table alone therefore reaches almost nothing on a
        // typical site — overrides are the exception, templates are the rule. So
        // when templates are in play, anchor the walk on the posts table and join
        // the AIOSEO row in; the per-post value still wins wherever it exists.
        $templates = ($import_titles || $import_descriptions)
            ? $this->get_aioseo_post_type_templates()
            : [];

        $template_post_types = [];
        foreach ($templates as $post_type => $template) {
            if (($import_titles && $template['title'] !== '')
                || ($import_descriptions && $template['description'] !== '')) {
                $template_post_types[] = $post_type;
            }
        }

        if (!empty($template_post_types)) {
            $type_placeholders = implode(', ', array_fill(0, count($template_post_types), '%s'));

            // Template mode visits every published post, not just the few with an
            // override, and AIOSEO's default description template is #post_excerpt
            // — which on a post without a manual excerpt runs the whole the_content
            // filter chain. On page-builder content that is a large fraction of a
            // second each, so the caller's batch of 50 can outlive a proxy read
            // timeout. Smaller batches cost more requests and finish.
            $batch_size = min($batch_size, 20);

            // COUNT and SELECT must describe the identical row set. is_complete
            // compares (offset + rows returned) against this count and the client
            // loops until they meet, with no iteration cap — a count broader than
            // the select spins forever, a narrower one stops early and silently
            // drops the remainder. Counting the joined set rather than the posts
            // table alone also keeps cardinality identical if the AIOSEO table
            // ever held two rows for one post: its post_id index is not unique.
            //
            // The post-type list governs which posts get a *template*, so it must
            // not also decide which posts get read at all: a post type with no
            // template — one AIOSEO never configured, one with SEO switched off,
            // one registered non-public — can still carry per-post overrides that
            // the override-only walk always imported. The second arm keeps every
            // row holding real AIOSEO data in scope whatever its post type, so
            // adding a template never removes anything from the import.
            // $joined_has_value is built only from the fixed $value_columns
            // allowlist above; table names are inline literals.
            $from_clause = "FROM {$wpdb->posts} p
                LEFT JOIN {$wpdb->prefix}aioseo_posts a ON a.post_id = p.ID
                WHERE p.post_status = 'publish'
                AND (p.post_type IN (" . implode(', ', array_fill(0, count($template_post_types), '%s')) . ") OR ({$joined_has_value}))";

            // Assembled from the literal fragments above; post types bind via prepare().
            $count_query = "SELECT COUNT(*) {$from_clause}";
            $total_posts = (int) $wpdb->get_var($wpdb->prepare($count_query, $template_post_types)); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- fragments built from fixed allowlists, values bound via prepare()

            $joined_columns = array_map(
                function ($column) {
                    return "a.{$column}";
                },
                $value_columns
            );
            // At least one import flag is set — the "no import options selected"
            // guard above returns before this point — so there is always at
            // least one value column to select.
            $joined_select = 'p.ID AS post_id, p.post_type AS post_type, '
                . implode(', ', $joined_columns);

            // Assembled from the literal fragments above; post types bind via prepare().
            $posts_query = "SELECT {$joined_select}
                 {$from_clause}
                 ORDER BY p.ID ASC
                 LIMIT %d OFFSET %d";
            $posts = $wpdb->get_results($wpdb->prepare($posts_query, array_merge($template_post_types, [$batch_size, $offset]))); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.DirectDatabaseQuery -- fragments built from fixed allowlists, values bound via prepare()
        } else {
            // No usable templates — e.g. only the social options are selected, or
            // aioseo_options_dynamic is missing. Fall back to the original
            // override-only walk so those sites behave exactly as before.
            $select_clause = implode(', ', array_merge(['post_id'], $value_columns));

            // $where_clause / $select_clause are built only from the fixed
            // $value_columns allowlist above.
            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $where_clause is built only from the fixed $value_columns allowlist above
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
            $total_posts = (int) $wpdb->get_var("
                SELECT COUNT(post_id)
                FROM {$wpdb->prefix}aioseo_posts
                WHERE ({$where_clause})
                AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
            ");
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared

            // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $select_clause / $where_clause are built only from the fixed $value_columns allowlist above
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
            $posts = $wpdb->get_results($wpdb->prepare("
                SELECT {$select_clause}
                FROM {$wpdb->prefix}aioseo_posts
                WHERE ({$where_clause})
                AND post_id IN (SELECT ID FROM {$wpdb->posts} WHERE post_status = 'publish')
                ORDER BY post_id ASC
                LIMIT %d OFFSET %d
            ", $batch_size, $offset));
            // phpcs:enable WordPress.DB.PreparedSQL.InterpolatedNotPrepared
        }

        $imported_count = 0;
        $skipped_count = 0;
        $unresolved = [];

        foreach ($posts as $aioseo_data) {
            $post_id = $aioseo_data->post_id;
            $updated = false;

            // Present only on the template-anchored query; the fallback walk has
            // no join to read it from.
            $post_type = isset($aioseo_data->post_type)
                ? (string) $aioseo_data->post_type
                : (string) get_post_type($post_id);

            $title_template = $templates[$post_type]['title'] ?? '';
            $desc_template  = $templates[$post_type]['description'] ?? '';

            // Import title.
            //
            // The per-post value wins; the site-wide template is the fallback,
            // matching the order AIOSEO itself resolves in. Destination and
            // write rules come from store_imported_post_value().
            if ($import_titles) {
                $raw_title = !empty($aioseo_data->title) ? (string) $aioseo_data->title : '';

                if ($raw_title === '' && $title_template !== '') {
                    $raw_title = $title_template;
                }

                $updated = $this->store_imported_post_value(
                    $post_id,
                    $raw_title,
                    'aioseo',
                    Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
                    $overwrite,
                    $unresolved
                );
            }

            // Import description — same reasoning as the title above.
            if ($import_descriptions) {
                $raw_desc           = !empty($aioseo_data->description) ? (string) $aioseo_data->description : '';
                $desc_from_template = false;

                if ($raw_desc === '' && $desc_template !== '') {
                    $raw_desc           = $desc_template;
                    $desc_from_template = true;
                }

                // Only template-derived values are trimmed. A '#post_content'
                // template resolves to the whole body, and AIOSEO emits that
                // untruncated — copying it verbatim would give every page a
                // multi-thousand-character description. What the customer
                // typed on the post is left exactly as they wrote it.
                $updated = $this->store_imported_post_value(
                    $post_id,
                    $raw_desc,
                    'aioseo',
                    Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
                    $overwrite,
                    $unresolved,
                    $desc_from_template
                ) || $updated;
            }

            // Import social text
            if ($import_social_text) {
                $aioseo_og_title = isset($aioseo_data->og_title) ? $aioseo_data->og_title : '';
                if (!empty($aioseo_og_title)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $aioseo_og_title,
                        'aioseo',
                        Metasync_Seo_Precedence::KEY_IMPORTED_OG_TITLE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $aioseo_og_desc = isset($aioseo_data->og_description) ? $aioseo_data->og_description : '';
                if (!empty($aioseo_og_desc)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $aioseo_og_desc,
                        'aioseo',
                        Metasync_Seo_Precedence::KEY_IMPORTED_OG_DESC,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $aioseo_tw_title = isset($aioseo_data->twitter_title) ? $aioseo_data->twitter_title : '';
                if (!empty($aioseo_tw_title)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $aioseo_tw_title,
                        'aioseo',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_TITLE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $aioseo_tw_desc = isset($aioseo_data->twitter_description) ? $aioseo_data->twitter_description : '';
                if (!empty($aioseo_tw_desc)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $aioseo_tw_desc,
                        'aioseo',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_DESC,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }
            }

            // Import social images
            if ($import_social_images) {
                $aioseo_og_image = isset($aioseo_data->og_image_custom_url) ? $aioseo_data->og_image_custom_url : '';
                if (!empty($aioseo_og_image)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $aioseo_og_image,
                        'aioseo',
                        Metasync_Seo_Precedence::KEY_IMPORTED_OG_IMAGE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }

                $aioseo_twitter_image = isset($aioseo_data->twitter_image_custom_url) ? $aioseo_data->twitter_image_custom_url : '';
                if (!empty($aioseo_twitter_image)) {
                    $updated = $this->store_imported_post_value(
                        $post_id,
                        (string) $aioseo_twitter_image,
                        'aioseo',
                        Metasync_Seo_Precedence::KEY_IMPORTED_TWITTER_IMAGE,
                        $overwrite,
                        $unresolved
                    ) || $updated;
                }
            }

            if ($updated) {
                $imported_count++;
            } else {
                $skipped_count++;
            }
        }

        $processed = $offset + count($posts);
        // An empty batch always ends the run. The client loops on !is_complete
        // with no iteration cap, so if the count and the select ever disagreed,
        // a batch returning no rows would leave processed stuck below total and
        // spin forever against the server.
        $is_complete = $processed >= $total_posts || empty($posts);

        return $this->attach_unresolved_report([
            'success' => true,
            'imported' => $imported_count,
            'skipped' => $skipped_count,
            'total' => $total_posts,
            'processed' => $processed,
            'is_complete' => $is_complete,
            'progress_percent' => $total_posts > 0 ? round(($processed / $total_posts) * 100) : 100,
            'message' => $is_complete
                ? "Import complete! Imported {$imported_count} posts, skipped {$skipped_count} posts."
                : "Processing... {$imported_count} imported, {$skipped_count} skipped."
        ], $unresolved);
    }

    /**
     * Import term-level SEO metadata from a third-party SEO plugin into
     * MetaSync term meta (`_metasync_*`).
     *
     * Mirrors import_seo_metadata() but operates on terms (wp_termmeta for
     * Yoast/Rank Math and the wp_aioseo_terms table for AIOSEO) and walks
     * every registered public taxonomy so category, post_tag, and custom
     * taxonomies are all covered.
     *
     * @param string $plugin  One of 'yoast', 'rankmath', 'aioseo'.
     * @param array  $options Batch options (overwrite_existing, batch_size, offset).
     * @return array ['success'=>bool,'imported'=>int,'skipped'=>int,'total'=>int,'message'=>string]
     */
    public function import_term_seo_metadata($plugin, $options = [])
    {
        $defaults = [
            'overwrite_existing' => false,
            'batch_size'         => 100,
            'offset'             => 0,
        ];
        $options = array_merge($defaults, $options);

        if (!in_array($plugin, ['yoast', 'rankmath', 'aioseo'], true)) {
            return [
                'success' => false,
                'message' => 'Invalid plugin specified.',
            ];
        }

        switch ($plugin) {
            case 'yoast':
                return $this->import_yoast_term_meta($options);
            case 'rankmath':
                return $this->import_rankmath_term_meta($options);
            case 'aioseo':
                return $this->import_aioseo_term_meta($options);
        }

        return [
            'success' => false,
            'message' => 'Unknown error occurred.',
        ];
    }

    /**
     * Import Yoast term meta into MetaSync term meta.
     *
     * @param array $options
     * @return array
     */
    private function import_yoast_term_meta($options)
    {
        $batch_size = intval($options['batch_size']);
        $offset     = intval($options['offset']);
        $overwrite  = (bool) $options['overwrite_existing'];

        // Yoast stores taxonomy term SEO data in the `wpseo_taxonomy_meta`
        // option (wp_options), NOT in wp_termmeta.  We must read from there.
        if (!class_exists('WPSEO_Taxonomy_Meta')) {
            return [
                'success' => false,
                'message' => 'Yoast SEO is not active or WPSEO_Taxonomy_Meta class not available.',
            ];
        }

        $taxonomies = array_values(get_taxonomies(['public' => true], 'names'));

        $terms = get_terms([
            'taxonomy'   => $taxonomies,
            'hide_empty' => false,
            'number'     => $batch_size,
            'offset'     => $offset,
        ]);

        if (is_wp_error($terms)) {
            return [
                'success' => false,
                'message' => $terms->get_error_message(),
            ];
        }

        // Title and description are handled separately below: they go to the
        // imported keys so they lose to OTTO, and they need their tokens
        // resolved. The rest keep their own keys.
        $field_map = [
            'wpseo_opengraph-title'       => '_metasync_og_title',
            'wpseo_opengraph-description' => '_metasync_og_description',
            'wpseo_canonical'             => '_metasync_canonical_url',
        ];

        $imported_map = [
            'wpseo_title' => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
            'wpseo_desc'  => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
        ];

        $imported = 0;
        $skipped  = 0;

        foreach ($terms as $term) {
            $term_updated = false;

            // Read from Yoast's wpseo_taxonomy_meta option via its API.
            $yoast_meta = WPSEO_Taxonomy_Meta::get_term_meta($term->term_id, $term->taxonomy);
            if (!is_array($yoast_meta)) {
                $yoast_meta = [];
            }

            foreach ($imported_map as $src_key => $dest_key) {
                $term_updated = $this->store_imported_term_value(
                    $term,
                    isset($yoast_meta[$src_key]) ? (string) $yoast_meta[$src_key] : '',
                    'yoast',
                    $dest_key,
                    $overwrite
                ) || $term_updated;
            }

            foreach ($field_map as $src_key => $dest_key) {
                $src_value = isset($yoast_meta[$src_key]) ? $yoast_meta[$src_key] : '';
                if ($src_value === '' || $src_value === null) {
                    continue;
                }

                $existing = get_term_meta($term->term_id, $dest_key, true);
                if (!empty($existing) && !$overwrite) {
                    continue;
                }

                // Validate canonical: never import a corrupted value.
                if ($dest_key === '_metasync_canonical_url') {
                    $src_value = Metasync_Canonical_Sanitizer::sanitize_for_save($src_value);
                    if ($src_value === '') {
                        continue;
                    }
                } else {
                    $src_value = $this->resolve_term_placeholders($src_value, $term, 'yoast');
                    if (trim($src_value) === '') {
                        continue;
                    }
                }

                update_term_meta($term->term_id, $dest_key, $src_value);
                $term_updated = true;
            }

            $noindex = isset($yoast_meta['wpseo_noindex']) ? $yoast_meta['wpseo_noindex'] : '';
            if ($noindex === 'noindex') {
                $existing = get_term_meta($term->term_id, '_metasync_robots_index', true);
                if (empty($existing) || $overwrite) {
                    update_term_meta($term->term_id, '_metasync_robots_index', 'noindex');
                    $term_updated = true;
                }
            }

            if ($term_updated) {
                $imported++;
            } else {
                $skipped++;
            }
        }

        $processed = $offset + count($terms);

        return [
            'success'  => true,
            'imported' => $imported,
            'skipped'  => $skipped,
            'total'    => $processed,
            'message'  => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
        ];
    }

    /**
     * Import Rank Math term meta into MetaSync term meta.
     *
     * @param array $options
     * @return array
     */
    private function import_rankmath_term_meta($options)
    {
        $batch_size = intval($options['batch_size']);
        $offset     = intval($options['offset']);
        $overwrite  = (bool) $options['overwrite_existing'];

        $taxonomies = array_values(get_taxonomies(['public' => true], 'names'));

        $terms = get_terms([
            'taxonomy'   => $taxonomies,
            'hide_empty' => false,
            'number'     => $batch_size,
            'offset'     => $offset,
        ]);

        if (is_wp_error($terms)) {
            return [
                'success' => false,
                'message' => $terms->get_error_message(),
            ];
        }

        // See import_yoast_term_meta(): title and description are routed to the
        // imported keys and token-resolved; the rest keep their own keys.
        $field_map = [
            'rank_math_facebook_title'       => '_metasync_og_title',
            'rank_math_facebook_description' => '_metasync_og_description',
            'rank_math_canonical_url'        => '_metasync_canonical_url',
        ];

        $imported_map = [
            'rank_math_title'       => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
            'rank_math_description' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
        ];

        $imported = 0;
        $skipped  = 0;

        foreach ($terms as $term) {
            $term_updated = false;

            foreach ($imported_map as $src_key => $dest_key) {
                $term_updated = $this->store_imported_term_value(
                    $term,
                    (string) get_term_meta($term->term_id, $src_key, true),
                    'rankmath',
                    $dest_key,
                    $overwrite
                ) || $term_updated;
            }

            foreach ($field_map as $src_key => $dest_key) {
                $src_value = get_term_meta($term->term_id, $src_key, true);
                if ($src_value === '' || $src_value === null) {
                    continue;
                }

                $existing = get_term_meta($term->term_id, $dest_key, true);
                if (!empty($existing) && !$overwrite) {
                    continue;
                }

                // Validate canonical: never import a corrupted value.
                if ($dest_key === '_metasync_canonical_url') {
                    $src_value = Metasync_Canonical_Sanitizer::sanitize_for_save($src_value);
                    if ($src_value === '') {
                        continue;
                    }
                } else {
                    $src_value = $this->resolve_term_placeholders($src_value, $term, 'rankmath');
                    if (trim($src_value) === '') {
                        continue;
                    }
                }

                update_term_meta($term->term_id, $dest_key, $src_value);
                $term_updated = true;
            }

            $robots_raw = get_term_meta($term->term_id, 'rank_math_robots', true);
            $robots = maybe_unserialize($robots_raw);
            if (is_array($robots) && in_array('noindex', $robots, true)) {
                $existing = get_term_meta($term->term_id, '_metasync_robots_index', true);
                if (empty($existing) || $overwrite) {
                    update_term_meta($term->term_id, '_metasync_robots_index', 'noindex');
                    $term_updated = true;
                }
            }

            if ($term_updated) {
                $imported++;
            } else {
                $skipped++;
            }
        }

        $processed = $offset + count($terms);

        return [
            'success'  => true,
            'imported' => $imported,
            'skipped'  => $skipped,
            'total'    => $processed,
            'message'  => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
        ];
    }

    /**
     * Import AIOSEO term meta (from the wp_aioseo_terms custom table) into
     * MetaSync term meta.
     *
     * @param array $options
     * @return array
     */
    private function import_aioseo_term_meta($options)
    {
        global $wpdb;

        $batch_size = intval($options['batch_size']);
        $offset     = intval($options['offset']);
        $overwrite  = (bool) $options['overwrite_existing'];

        $table = $wpdb->prefix . 'aioseo_terms';

        $table_exists = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)); // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
        if ($table_exists !== $table) {
            return [
                'success' => false,
                'message' => 'AIOSEO terms table not found.',
            ];
        }

        $rows = $wpdb->get_results($wpdb->prepare( // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- one-shot import tool — bulk reads from third-party SEO plugin storage; no bulk WordPress API exists
            "SELECT term_id, title, description, og_title, og_description, canonical_url, robots_noindex
             FROM {$wpdb->prefix}aioseo_terms
             ORDER BY term_id ASC
             LIMIT %d OFFSET %d",
            $batch_size,
            $offset
        ));

        $imported = 0;
        $skipped  = 0;

        foreach ($rows as $row) {
            $term_id = (int) $row->term_id;
            if ($term_id <= 0) {
                continue;
            }

            // The resolver needs the term's own name and description, and a row
            // can outlive the term it points at.
            $term = get_term($term_id);
            if (!$term || is_wp_error($term)) {
                $skipped++;
                continue;
            }

            $term_updated = false;

            // See import_yoast_term_meta(): title and description are routed to
            // the imported keys and token-resolved; the rest keep their own keys.
            $field_map = [
                'og_title'      => '_metasync_og_title',
                'og_description' => '_metasync_og_description',
                'canonical_url' => '_metasync_canonical_url',
            ];

            $imported_map = [
                'title'       => Metasync_Seo_Precedence::KEY_IMPORTED_TITLE,
                'description' => Metasync_Seo_Precedence::KEY_IMPORTED_DESC,
            ];

            foreach ($imported_map as $column => $dest_key) {
                $term_updated = $this->store_imported_term_value(
                    $term,
                    isset($row->$column) ? (string) $row->$column : '',
                    'aioseo',
                    $dest_key,
                    $overwrite
                ) || $term_updated;
            }

            foreach ($field_map as $column => $dest_key) {
                $value = isset($row->$column) ? $row->$column : '';
                if ($value === '' || $value === null) {
                    continue;
                }

                $existing = get_term_meta($term_id, $dest_key, true);
                if (!empty($existing) && !$overwrite) {
                    continue;
                }

                // Validate canonical: never import a corrupted value.
                if ($dest_key === '_metasync_canonical_url') {
                    $value = Metasync_Canonical_Sanitizer::sanitize_for_save($value);
                    if ($value === '') {
                        continue;
                    }
                } else {
                    $value = $this->resolve_term_placeholders($value, $term, 'aioseo');
                    if (trim($value) === '' || $this->has_unresolved_placeholders($value, 'aioseo')) {
                        continue;
                    }
                }

                update_term_meta($term_id, $dest_key, $value);
                $term_updated = true;
            }

            if (!empty($row->robots_noindex) && (int) $row->robots_noindex === 1) {
                $existing = get_term_meta($term_id, '_metasync_robots_index', true);
                if (empty($existing) || $overwrite) {
                    update_term_meta($term_id, '_metasync_robots_index', 'noindex');
                    $term_updated = true;
                }
            }

            if ($term_updated) {
                $imported++;
            } else {
                $skipped++;
            }
        }

        $processed = $offset + count($rows);

        return [
            'success'  => true,
            'imported' => $imported,
            'skipped'  => $skipped,
            'total'    => $processed,
            'message'  => "Processed {$processed} terms: {$imported} imported, {$skipped} skipped.",
        ];
    }
}

```
