<?php
/**
 * TamRank AIOSEO Migrator
 *
 * Reads All in One SEO (v4) post data from the {$wpdb->prefix}aioseo_posts
 * table and maps it to TamRank post meta. Unlike Yoast/Rank Math, AIOSEO
 * stores nothing in wp_postmeta, so this migrator implements its own
 * get_post_ids() / count_category() against the custom table. The
 * migration manager detects that capability via method_exists() and
 * delegates — the postmeta-based migrators are untouched.
 */

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

class TamRank_AIOSEO_Migrator {

    /** @var bool|null Cached table-existence check (cached per instance) */
    private $table_exists = null;

    /** @var bool|null Cached schema-column check (column added in AIOSEO 4.2.5) */
    private $schema_column = null;

    /** @var string|null Cached resolved title separator */
    private $separator = null;

    /**
     * AIOSEO smart-tag ids that map onto a TamRank runtime variable.
     * TamRank resolves only %title% %sitename% %excerpt% %date%
     * %category% %tag% (tamrank_replace_dynamic_variables, helpers.php).
     */
    private $tag_map = array(
        'post_title'        => '%title%',
        'site_title'        => '%sitename%',
        'blog_title'        => '%sitename%',
        'post_excerpt'      => '%excerpt%',
        'post_excerpt_only' => '%excerpt%',
        'post_date'         => '%date%',
        'category'          => '%category%',
        'taxonomy_title'    => '%category%',
    );

    /**
     * Complete AIOSEO 4.9.10 smart-tag registry (app/Common/Utils/Tags.php).
     * Registered tags that are not in $tag_map are stripped so raw '#…'
     * tokens never leak into indexed frontend titles. Tokens NOT in this
     * registry (e.g. a real hashtag '#SEO') are left untouched — mirroring
     * AIOSEO's own behaviour of only resolving registered tags.
     * custom_field and tax_name take a '-suffix' and are stripped up front.
     */
    private $known_tags = array(
        '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', '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_parent_name', 'taxonomy_description',
        'taxonomy_title',
    );

    /**
     * AIOSEO graph name → TamRank primary schema type. Conservative:
     * anything not listed here (Person, Movie, Dataset, ProductReview,
     * Car, site-level graphs, …) is skipped entirely — TamRank's own
     * auto-detection takes over later. Article maps to BlogPosting
     * because the detector's SCHEMA_TYPES whitelist has no 'Article'
     * (class-detection-result.php:29), matching the Yoast migrator.
     */
    private $schema_map = array(
        'WebPage'             => 'WebPage',
        'Article'             => 'BlogPosting',
        'BlogPosting'         => 'BlogPosting',
        'NewsArticle'         => 'NewsArticle',
        'Product'             => 'Product',
        'Recipe'              => 'Recipe',
        'Course'              => 'Course',
        'Service'             => 'Service',
        'Event'               => 'Event',
        'JobPosting'          => 'JobPosting',
        'LocalBusiness'       => 'LocalBusiness',
        'SoftwareApplication' => 'SoftwareApplication',
    );

    /** AIOSEO graphs that become TamRank extras (never primary). */
    private $schema_extras_map = array(
        'FAQPage' => 'FAQPage',
    );

    /* ------------------------------------------------------------------
     * Table / option helpers
     * ----------------------------------------------------------------*/

    /**
     * Whether the aioseo_posts table exists. Cached per request; every
     * public entry point checks this first so non-AIOSEO sites never
     * run a query against a missing table (keeps $wpdb->last_error and
     * debug.log clean on every detect call).
     */
    public function table_exists() {
        if ( $this->table_exists !== null ) {
            return $this->table_exists;
        }

        global $wpdb;
        $table = $wpdb->prefix . 'aioseo_posts';

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- existence check for a third-party table during admin-context migration; caching not applicable
        $this->table_exists = ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) ) === $table );

        return $this->table_exists;
    }

    /**
     * Whether the `schema` column exists. It was only added in AIOSEO 4.2.5
     * (Aug 2022): a leftover table from an install deactivated before that
     * permanently lacks the column, and an unconditional predicate would
     * break EVERY count with an unknown-column error — silently hiding
     * AIOSEO as a source and polluting $wpdb->last_error on each detect.
     */
    private function schema_column_exists() {
        if ( $this->schema_column !== null ) {
            return $this->schema_column;
        }

        if ( ! $this->table_exists() ) {
            $this->schema_column = false;
            return false;
        }

        global $wpdb;

        // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- column introspection on a third-party table; only the table identifier is interpolated; admin-context migration, caching not applicable
        $this->schema_column = (bool) $wpdb->get_var( $wpdb->prepare(
            "SHOW COLUMNS FROM {$wpdb->prefix}aioseo_posts LIKE %s",
            'schema'
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter

        return $this->schema_column;
    }

    /**
     * Resolve AIOSEO's title separator (smart tag #separator_sa).
     * Stored HTML-encoded in the aioseo_options JSON at
     * searchAppearance.global.separator (e.g. '&#45;'). Fallback '-'.
     */
    private function get_separator() {
        if ( $this->separator !== null ) {
            return $this->separator;
        }

        $this->separator = '-';

        $raw = get_option( 'aioseo_options' );
        if ( is_string( $raw ) && $raw !== '' ) {
            $options = json_decode( $raw );
            if ( isset( $options->searchAppearance->global->separator )
                && is_string( $options->searchAppearance->global->separator ) ) {
                $decoded = trim( html_entity_decode( $options->searchAppearance->global->separator, ENT_QUOTES ) );
                if ( $decoded !== '' ) {
                    $this->separator = $decoded;
                }
            }
        }

        return $this->separator;
    }

    /* ------------------------------------------------------------------
     * Category predicates — single source of truth
     * ----------------------------------------------------------------*/

    /**
     * SQL predicate for one migration category, against alias `ap`.
     *
     * This is THE single source of truth: count_category() (wizard card
     * counts) and get_post_ids() (batch selection) both consume it, so
     * the card count always equals the batch candidate set.
     *
     * Notes per category:
     * - focus_keywords: the '"focus":{"keyphrase":"' anchor matches only
     *   the focus keyphrase, never the additional[] entries (those lack
     *   the '"focus":{' prefix) and never escaped quotes inside values
     *   (JSON escapes '"' as '\"'). The NOT LIKE guard excludes the
     *   seeded-but-empty focus ('"keyphrase":""').
     * - robots: a per-post override only exists when robots_default = 0;
     *   stale flags under robots_default = 1 are ignored by AIOSEO's own
     *   runtime and MUST NOT be counted or imported.
     * - social image: only og_image_type = 'custom_image' carries a
     *   user-chosen URL (og_image_custom_url); og_image_url is a stale
     *   cache, other og_image_type values resolve dynamically.
     * - schema: '"graphs":[{' requires a real (ex-Pro) graph entry; the
     *   Lite default blob contains '"graphs":[]' and can never match.
     *   The leading quote keeps blockGraphs/customGraphs from matching.
     *
     * @param string $category
     * @return array|null { @type string $where, @type array $params } or null for unknown categories.
     */
    public function get_category_predicate( $category ) {
        global $wpdb;

        switch ( $category ) {
            case 'meta_titles':
                return array( 'where' => "( ap.title IS NOT NULL AND ap.title <> '' )", 'params' => array() );
            case 'meta_descriptions':
                return array( 'where' => "( ap.description IS NOT NULL AND ap.description <> '' )", 'params' => array() );
            case 'focus_keywords':
                return array(
                    'where'  => '( ap.keyphrases LIKE %s AND ap.keyphrases NOT LIKE %s )',
                    'params' => array(
                        '%' . $wpdb->esc_like( '"focus":{"keyphrase":"' ) . '%',
                        '%' . $wpdb->esc_like( '"focus":{"keyphrase":""' ) . '%',
                    ),
                );
            case 'robots':
                return array( 'where' => '( ap.robots_default = 0 AND ( ap.robots_noindex = 1 OR ap.robots_nofollow = 1 ) )', 'params' => array() );
            case 'canonical':
                return array( 'where' => "( ap.canonical_url IS NOT NULL AND ap.canonical_url <> '' )", 'params' => array() );
            case 'social':
                return array(
                    'where'  => "( ( ap.og_title IS NOT NULL AND ap.og_title <> '' )
                        OR ( ap.og_description IS NOT NULL AND ap.og_description <> '' )
                        OR ( ap.og_image_type = 'custom_image' AND ap.og_image_custom_url IS NOT NULL AND ap.og_image_custom_url <> '' ) )",
                    'params' => array(),
                );
            case 'schema':
                // Pre-4.2.5 leftover tables have no `schema` column — the
                // category simply doesn't exist for them (counts to 0).
                if ( ! $this->schema_column_exists() ) {
                    return null;
                }
                return array(
                    'where'  => '( ap.`schema` LIKE %s )',
                    'params' => array( '%' . $wpdb->esc_like( '"graphs":[{' ) . '%' ),
                );
        }

        return null;
    }

    /* ------------------------------------------------------------------
     * Selection / counting (consumed by manager + scanner)
     * ----------------------------------------------------------------*/

    /**
     * Post IDs that have importable data for the selected categories.
     *
     * Contract (the manager's pagination + done-check depend on it):
     * ascending post_id, at most $limit rows, offset-based, and the same
     * post_status whitelist as the postmeta path (manager:178). The
     * wp_posts JOIN is mandatory: aioseo_posts has no post_status column
     * and holds rows for trashed posts and attachments ('inherit').
     * DISTINCT is mandatory: post_id is not unique in aioseo_posts.
     */
    public function get_post_ids( $categories, $offset, $limit ) {
        if ( ! $this->table_exists() ) {
            return array();
        }

        global $wpdb;

        $where  = array();
        $params = array();
        foreach ( $categories as $category ) {
            $pred = $this->get_category_predicate( $category );
            if ( $pred ) {
                $where[] = $pred['where'];
                $params  = array_merge( $params, $pred['params'] );
            }
        }

        if ( empty( $where ) ) {
            return array();
        }

        $params[] = $offset;
        $params[] = $limit;

        // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only table identifiers and predicate fragments built in get_category_predicate() are interpolated; all user values use prepare() placeholders; read-only migration query on a third-party table in admin context, caching not applicable
        return $wpdb->get_col( $wpdb->prepare(
            "SELECT DISTINCT ap.post_id
             FROM {$wpdb->prefix}aioseo_posts ap
             INNER JOIN {$wpdb->posts} p ON ap.post_id = p.ID
             WHERE p.post_status IN ('publish', 'draft', 'pending', 'future', 'private')
             AND ( " . implode( ' OR ', $where ) . " )
             ORDER BY ap.post_id ASC
             LIMIT %d, %d",
            ...$params
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
    }

    /**
     * Count for one wizard category card. Same predicate + JOIN + status
     * whitelist as get_post_ids(), so card count == batch candidate set.
     * prepare() is only called when the predicate carries placeholders
     * (a zero-placeholder prepare() triggers _doing_it_wrong).
     */
    public function count_category( $category ) {
        if ( ! $this->table_exists() ) {
            return 0;
        }

        $pred = $this->get_category_predicate( $category );
        if ( ! $pred ) {
            return 0;
        }

        global $wpdb;

        $sql = "SELECT COUNT(DISTINCT ap.post_id)
             FROM {$wpdb->prefix}aioseo_posts ap
             INNER JOIN {$wpdb->posts} p ON ap.post_id = p.ID
             WHERE p.post_status IN ('publish', 'draft', 'pending', 'future', 'private')
             AND {$pred['where']}";

        // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only table identifiers and predicate fragments built in get_category_predicate() are interpolated; read-only admin-context count, caching not applicable
        if ( ! empty( $pred['params'] ) ) {
            return (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$pred['params'] ) );
        }
        return (int) $wpdb->get_var( $sql );
        // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
    }

    /**
     * Number of posts with ANY importable data — the detection card's
     * data_count. Union of every category predicate, so the number shown
     * on the detect step equals what the wizard can actually reach.
     */
    public function get_data_count() {
        if ( ! $this->table_exists() ) {
            return 0;
        }

        global $wpdb;

        $categories = array( 'meta_titles', 'meta_descriptions', 'focus_keywords', 'robots', 'canonical', 'social', 'schema' );
        $where      = array();
        $params     = array();
        foreach ( $categories as $category ) {
            $pred = $this->get_category_predicate( $category );
            if ( $pred ) {
                $where[] = $pred['where'];
                $params  = array_merge( $params, $pred['params'] );
            }
        }

        $sql = "SELECT COUNT(DISTINCT ap.post_id)
             FROM {$wpdb->prefix}aioseo_posts ap
             INNER JOIN {$wpdb->posts} p ON ap.post_id = p.ID
             WHERE p.post_status IN ('publish', 'draft', 'pending', 'future', 'private')
             AND ( " . implode( ' OR ', $where ) . " )";

        // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only table identifiers and predicate fragments built in get_category_predicate() are interpolated; read-only admin-context count, caching not applicable
        return (int) $wpdb->get_var( $wpdb->prepare( $sql, ...$params ) );
        // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
    }

    /* ------------------------------------------------------------------
     * Migrator contract (informal) — parity with Yoast/RM migrators
     * ----------------------------------------------------------------*/

    /**
     * Postmeta source keys — AIOSEO has none. Documented stubs so any
     * generic caller of the informal migrator contract stays non-fatal;
     * unreachable in practice behind the method_exists(get_post_ids) seam.
     */
    public function get_source_keys( $category ) {
        return array();
    }

    public function get_all_source_keys( $categories ) {
        return array();
    }

    /**
     * Migrate one post. Mirrors the Yoast/RM write contract exactly:
     * never overwrite existing TamRank data, back up originals into
     * _tamrank_migration_backup (merge; earlier backup wins) so the
     * unchanged rollback restores everything.
     */
    public function migrate_post( $post_id, $categories ) {
        $migrated = array();
        $backup   = array();

        $row = $this->get_row( $post_id );
        if ( ! $row ) {
            return array( 'success' => true, 'migrated' => array() );
        }

        foreach ( $categories as $category ) {
            switch ( $category ) {
                case 'meta_titles':
                    $this->migrate_text_field( $post_id, (string) $row->title, '_tam_rank_meta_title', true, $migrated, $backup );
                    break;

                case 'meta_descriptions':
                    $this->migrate_text_field( $post_id, (string) $row->description, '_tam_rank_meta_description', true, $migrated, $backup );
                    break;

                case 'focus_keywords':
                    $keyphrases = json_decode( (string) $row->keyphrases );
                    $keyword    = isset( $keyphrases->focus->keyphrase ) ? (string) $keyphrases->focus->keyphrase : '';
                    $this->migrate_text_field( $post_id, $keyword, '_tam_rank_focus_keyword', false, $migrated, $backup );
                    break;

                case 'canonical':
                    $this->migrate_text_field( $post_id, (string) $row->canonical_url, '_tam_rank_canonical', false, $migrated, $backup );
                    break;

                case 'social':
                    $this->migrate_text_field( $post_id, (string) $row->og_title, '_tam_rank_social_title', true, $migrated, $backup );
                    $this->migrate_text_field( $post_id, (string) $row->og_description, '_tam_rank_social_description', true, $migrated, $backup );
                    if ( $row->og_image_type === 'custom_image' ) {
                        $this->migrate_text_field( $post_id, (string) $row->og_image_custom_url, '_tam_rank_social_image', false, $migrated, $backup );
                    }
                    break;

                case 'robots':
                    // Per-post robots overrides only exist when robots_default = 0.
                    // Stale flags under robots_default = 1 are ignored by AIOSEO's
                    // runtime and importing them would silently de-index pages.
                    if ( (int) $row->robots_default === 0 ) {
                        if ( (int) $row->robots_noindex === 1 ) {
                            $this->migrate_flag( $post_id, '_tam_rank_noindex', $migrated, $backup );
                        }
                        if ( (int) $row->robots_nofollow === 1 ) {
                            $this->migrate_flag( $post_id, '_tam_rank_nofollow', $migrated, $backup );
                        }
                    }
                    break;

                case 'schema':
                    $this->migrate_schema( $post_id, $row, $migrated, $backup );
                    break;
            }
        }

        // Save backup — identical contract to the Yoast migrator: merge
        // into any backup from a prior run so earlier-recorded originals
        // are never lost (existing keys win).
        if ( ! empty( $migrated ) ) {
            $existing_backup = get_post_meta( $post_id, '_tamrank_migration_backup', true );
            if ( is_array( $existing_backup ) ) {
                $backup = array_merge( $backup, $existing_backup );
            }
            update_post_meta( $post_id, '_tamrank_migration_backup', $backup );
        }

        return array(
            'success'  => true,
            'migrated' => $migrated,
        );
    }

    /**
     * Read the aioseo_posts row for one post. post_id is not unique in
     * this table — read the newest row deterministically.
     */
    private function get_row( $post_id ) {
        if ( ! $this->table_exists() ) {
            return null;
        }

        global $wpdb;

        // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only the table identifier is interpolated; read-only migration query in admin context, caching not applicable
        return $wpdb->get_row( $wpdb->prepare(
            "SELECT * FROM {$wpdb->prefix}aioseo_posts WHERE post_id = %d ORDER BY id DESC LIMIT 1",
            $post_id
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
    }

    /**
     * Write one text value to a TamRank meta key: strict emptiness checks
     * (never empty(): a literal '0' is valid data), optional smart-tag
     * transform, skip when the transform strips the value to nothing,
     * never overwrite existing TamRank data, record the original in the
     * backup exactly like the Yoast migrator does.
     */
    private function migrate_text_field( $post_id, $value, $target_key, $transform, &$migrated, &$backup ) {
        if ( '' === trim( $value ) ) {
            return;
        }

        if ( $transform ) {
            $value = $this->transform_smart_tags( $value );
            if ( '' === trim( $value ) ) {
                return;
            }
        }

        $existing = get_post_meta( $post_id, $target_key, true );
        if ( ! empty( $existing ) ) {
            return;
        }

        $backup[ $target_key ] = $existing;
        update_post_meta( $post_id, $target_key, $value );
        $migrated[] = $target_key;
    }

    /**
     * Write a robots flag (mirrors the Yoast migrate_robots contract).
     */
    private function migrate_flag( $post_id, $target_key, &$migrated, &$backup ) {
        $existing = get_post_meta( $post_id, $target_key, true );
        if ( ! empty( $existing ) ) {
            return;
        }

        $backup[ $target_key ] = $existing;
        update_post_meta( $post_id, $target_key, 1 );
        $migrated[] = $target_key;
    }

    /**
     * Migrate an explicit (ex-Pro) schema choice. Lite rows never match:
     * their graphs[] is always empty. Mirrors the Yoast migrate_schema
     * guard/backup contract; unknown graph names are skipped so TamRank's
     * own auto-detection takes over.
     */
    private function migrate_schema( $post_id, $row, &$migrated, &$backup ) {
        // Pre-4.2.5 rows have no schema column at all (see schema_column_exists).
        if ( ! isset( $row->schema ) ) {
            return;
        }

        $schema = json_decode( (string) $row->schema );
        if ( ! isset( $schema->graphs ) || ! is_array( $schema->graphs ) || empty( $schema->graphs ) ) {
            return;
        }

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

        $tamrank_type = '';
        $extras       = array();

        foreach ( $schema->graphs as $graph ) {
            $name = isset( $graph->graphName ) ? (string) $graph->graphName : '';
            if ( $name === '' ) {
                continue;
            }
            if ( isset( $this->schema_extras_map[ $name ] ) ) {
                $extras[] = $this->schema_extras_map[ $name ];
                continue;
            }
            if ( $tamrank_type === '' && isset( $this->schema_map[ $name ] ) ) {
                $tamrank_type = $this->schema_map[ $name ];
            }
        }

        if ( $tamrank_type === '' && ! empty( $extras ) ) {
            $tamrank_type = $this->default_primary_for_post( $post );
        }

        if ( $tamrank_type === '' ) {
            return;
        }

        // Don't overwrite existing TamRank schema config (Yoast parity).
        $existing_type    = get_post_meta( $post_id, 'tamrank_schema_type', true );
        $existing_enabled = get_post_meta( $post_id, 'tamrank_schema_enabled', true );

        if ( ! empty( $existing_type ) || $existing_enabled === '1' ) {
            return;
        }

        $backup['tamrank_schema_type']         = $existing_type;
        $backup['tamrank_schema_enabled']      = $existing_enabled;
        $backup['tamrank_schema_bron']         = get_post_meta( $post_id, 'tamrank_schema_bron', true );
        $backup['_tamrank_schema_auto_extras'] = get_post_meta( $post_id, '_tamrank_schema_auto_extras', true );

        update_post_meta( $post_id, 'tamrank_schema_type', $tamrank_type );
        update_post_meta( $post_id, 'tamrank_schema_enabled', '1' );
        update_post_meta( $post_id, 'tamrank_schema_bron', 'automatisch' );

        $migrated[] = 'tamrank_schema_type';
        $migrated[] = 'tamrank_schema_enabled';
        $migrated[] = 'tamrank_schema_bron';

        if ( ! empty( $extras ) ) {
            update_post_meta( $post_id, '_tamrank_schema_auto_extras', array_values( array_unique( $extras ) ) );
            $migrated[] = '_tamrank_schema_auto_extras';
        }
    }

    /**
     * Derive TamRank primary schema type from post_type (Yoast parity).
     */
    private function default_primary_for_post( $post ) {
        if ( $post->post_type === 'post' ) {
            return 'BlogPosting';
        }
        if ( $post->post_type === 'product' ) {
            return 'Product';
        }
        return 'WebPage';
    }

    /* ------------------------------------------------------------------
     * Smart-tag transform
     * ----------------------------------------------------------------*/

    /**
     * Transform AIOSEO smart tags to TamRank template variables.
     *
     * AIOSEO tags are '#'-prefixed with NO closing delimiter, matched
     * case-insensitively with the word boundary (?![a-zA-Z0-9_]) — the
     * boundary is what keeps '#category' from corrupting '#categories'
     * (naive str_replace would produce '%category%s'). Suffix tags
     * (#custom_field-x, #tax_name-x) are stripped first so no '-x'
     * residue survives. Registered tags without a TamRank equivalent are
     * stripped; unregistered '#tokens' (real hashtags) stay untouched.
     */
    public function transform_smart_tags( $value ) {
        if ( strpos( $value, '#' ) === false ) {
            return $value;
        }

        $value = preg_replace( '/#(custom_field|tax_name)-[a-zA-Z0-9_-]+/i', '', $value );

        // The separator is a user-configurable free-text option and lands in
        // a preg_replace REPLACEMENT: escape backslashes and dollars so a
        // pathological separator like '$1' or '\1' cannot act as a backref
        // (which would silently eat or leak the token).
        $separator = strtr( $this->get_separator(), array( '\\' => '\\\\', '$' => '\\$' ) );

        foreach ( $this->known_tags as $tag ) {
            if ( strpos( $value, '#' ) === false ) {
                break;
            }
            if ( $tag === 'separator_sa' ) {
                $replacement = $separator;
            } elseif ( isset( $this->tag_map[ $tag ] ) ) {
                $replacement = $this->tag_map[ $tag ];
            } else {
                $replacement = '';
            }
            $value = preg_replace( '/#' . $tag . '(?![a-zA-Z0-9_])/i', $replacement, $value );
        }

        // Clean up double spaces and trim (mirrors Yoast transform_variables).
        $value = preg_replace( '/\s{2,}/', ' ', $value );
        return trim( $value );
    }
}
