<?php
/**
 * TamRank Rank Math Migrator
 *
 * Maps Rank Math meta keys to TamRank equivalents
 * and transforms Rank Math template variables.
 * Handles serialized robots array.
 */

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

class TamRank_RankMath_Migrator {

    /**
     * Rank Math → TamRank meta key mapping (grouped by category).
     */
    private $key_map = array(
        'meta_titles' => array(
            'rank_math_title' => '_tam_rank_meta_title',
        ),
        'meta_descriptions' => array(
            'rank_math_description' => '_tam_rank_meta_description',
        ),
        'focus_keywords' => array(
            'rank_math_focus_keyword' => '_tam_rank_focus_keyword',
        ),
        'canonical' => array(
            'rank_math_canonical_url' => '_tam_rank_canonical',
        ),
        'social' => array(
            'rank_math_facebook_title'       => '_tam_rank_social_title',
            'rank_math_facebook_description' => '_tam_rank_social_description',
            'rank_math_facebook_image'       => '_tam_rank_social_image',
        ),
    );

    /**
     * Rank Math template variable → TamRank runtime variable transform map.
     *
     * TamRank runtime (see tamrank_replace_dynamic_variables in helpers.php)
     * only resolves these 6 placeholders: %title%, %sitename%, %excerpt%,
     * %date%, %category%, %tag%. Rank Math uses the same %xxx% syntax, so
     * supported tokens are left identical; unsupported tokens are stripped
     * to prevent raw leakage into page titles on the frontend.
     */
    private $variable_map = array(
        '%title%'             => '%title%',
        '%sitename%'          => '%sitename%',
        '%sep%'               => '|',
        '%category%'          => '%category%',
        '%tag%'               => '%tag%',
        '%excerpt%'           => '%excerpt%',
        '%date%'              => '%date%',
        '%modified%'          => '%date%',     // no separate modified var — fall back to date
        '%term%'              => '%title%',    // closest fallback
        '%term_description%'  => '%excerpt%',
        '%filename%'          => '%title%',
        '%org_name%'          => '%sitename%',
        // Unsupported at runtime — strip to prevent raw token leakage
        '%name%'              => '',
        '%search_query%'      => '',
        '%page%'              => '',
        '%pt_single%'         => '',
        '%pt_plural%'         => '',
        '%parent_title%'      => '',
        '%currentyear%'       => '',
        '%currentmonth%'      => '',
        '%currentday%'        => '',
        '%focuskw%'           => '',
        '%count(.*?)%'        => '',
        '%id%'                => '',
        '%url%'               => '',
    );

    /**
     * Rank Math rich snippet type → TamRank schema type mapping.
     *
     * Rank Math stores a single top-level type in `rank_math_rich_snippet`
     * (lowercase slug); for articles there's a subtype in
     * `rank_math_snippet_article_type`.
     *
     * TamRank splits schema into primair (1 per post) and extras (0-N).
     * FAQPage / HowTo / VideoObject are ALWAYS extras in TamRank's design
     * — even if Rank Math user picked them as top-level snippet. Migration
     * writes those to _tamrank_schema_auto_extras and derives the primary
     * type from post_type.
     *
     * Unlisted RM types (book, music, person, profilepage, webpage, website,
     * collectionpage, and all PRO types like movie/dataset/claimreview) are
     * intentionally skipped — they produce no Google rich results in 2026
     * and would lose specificity by forcing them to WebPage.
     */
    private $schema_snippet_map = array(
        'article'      => 'BlogPosting',  // refined by snippet_article_type
        'blogposting'  => 'BlogPosting',
        'newsarticle'  => 'NewsArticle',
        'course'       => 'Course',
        'event'        => 'Event',
        'jobposting'   => 'JobPosting',
        'local'        => 'LocalBusiness',
        'restaurant'   => 'LocalBusiness',
        'product'      => 'Product',
        'recipe'       => 'Recipe',
        'service'      => 'Service',
        'software'     => 'SoftwareApplication',
    );

    /**
     * Rank Math rich snippet types that map to TamRank extras.
     * Primary type is derived from post_type in migrate_schema().
     */
    private $schema_snippet_extras = array(
        'video' => 'VideoObject',
        'faq'   => 'FAQPage',
        'howto' => 'HowTo',
    );

    private $schema_article_subtype_map = array(
        'Article'      => 'BlogPosting',
        'BlogPosting'  => 'BlogPosting',
        'NewsArticle'  => 'NewsArticle',
    );

    /**
     * Get source meta keys for a given category.
     */
    public function get_source_keys( $category ) {
        if ( $category === 'robots' ) {
            return array( 'rank_math_robots' );
        }

        if ( $category === 'schema' ) {
            return array( 'rank_math_rich_snippet', 'rank_math_snippet_article_type' );
        }

        if ( ! isset( $this->key_map[ $category ] ) ) {
            return array();
        }

        return array_keys( $this->key_map[ $category ] );
    }

    /**
     * Get all source meta keys across all categories.
     */
    public function get_all_source_keys( $categories ) {
        $keys = array();
        foreach ( $categories as $cat ) {
            $keys = array_merge( $keys, $this->get_source_keys( $cat ) );
        }
        return array_unique( $keys );
    }

    /**
     * Migrate meta data for a single post.
     *
     * @param int   $post_id
     * @param array $categories
     * @return array
     */
    public function migrate_post( $post_id, $categories ) {
        $migrated = array();
        $backup   = array();

        foreach ( $categories as $category ) {
            if ( $category === 'robots' ) {
                $result = $this->migrate_robots( $post_id );
                if ( $result ) {
                    $migrated = array_merge( $migrated, $result['migrated'] );
                    $backup   = array_merge( $backup, $result['backup'] );
                }
                continue;
            }

            if ( $category === 'schema' ) {
                $result = $this->migrate_schema( $post_id );
                if ( $result ) {
                    $migrated = array_merge( $migrated, $result['migrated'] );
                    $backup   = array_merge( $backup, $result['backup'] );
                }
                continue;
            }

            if ( ! isset( $this->key_map[ $category ] ) ) {
                continue;
            }

            foreach ( $this->key_map[ $category ] as $source_key => $target_key ) {
                $source_value = get_post_meta( $post_id, $source_key, true );
                if ( empty( $source_value ) ) {
                    continue;
                }

                // Rank Math focus_keyword can contain multiple keywords separated by comma
                // Only take the first one as primary
                if ( $category === 'focus_keywords' && strpos( $source_value, ',' ) !== false ) {
                    $source_value = trim( explode( ',', $source_value )[0] );
                }

                // Don't overwrite existing TamRank data
                $existing = get_post_meta( $post_id, $target_key, true );
                if ( ! empty( $existing ) ) {
                    continue;
                }

                // Transform variables in title/description
                if ( in_array( $category, array( 'meta_titles', 'meta_descriptions' ), true ) ) {
                    $source_value = $this->transform_variables( $source_value );
                }

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

        // Save backup
        if ( ! empty( $migrated ) ) {
            // Merge into any backup from a prior run so earlier-recorded keys are
            // not lost on a second migration (preserves rollback fidelity).
            $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,
        );
    }

    /**
     * Migrate Rank Math robots meta.
     *
     * Rank Math stores robots as a serialized array: ['index', 'follow']
     * or ['noindex', 'nofollow', 'noarchive', etc.]
     */
    private function migrate_robots( $post_id ) {
        $migrated = array();
        $backup   = array();

        // get_post_meta() already ran maybe_unserialize() once. A verbatim
        // double-serialised value therefore arrives here as a serialised
        // string, and a second maybe_unserialize() would instantiate whatever
        // object it describes — core implements it as a bare @unserialize(),
        // so __wakeup()/__destruct() fire before the is_array() check below
        // ever sees the result. rank_math_robots is not protected meta, so a
        // contributor can plant those bytes. Same hardening as the redirect
        // sources below: is_serialized() keeps maybe_unserialize()'s exact
        // behaviour for plain and single-serialised values, allowed_classes
        // => false turns any object into __PHP_Incomplete_Class without
        // constructing it.
        $robots = get_post_meta( $post_id, 'rank_math_robots', true );
        if ( is_serialized( $robots ) ) {
            // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- third-party data; object instantiation is explicitly disabled via allowed_classes
            $robots = @unserialize( trim( $robots ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed third-party payload must not emit a notice; the is_array() check below rejects it
        }

        if ( ! is_array( $robots ) || empty( $robots ) ) {
            return null;
        }

        // Noindex
        if ( in_array( 'noindex', $robots, true ) ) {
            $existing = get_post_meta( $post_id, '_tam_rank_noindex', true );
            if ( empty( $existing ) ) {
                $backup['_tam_rank_noindex'] = $existing;
                update_post_meta( $post_id, '_tam_rank_noindex', 1 );
                $migrated[] = '_tam_rank_noindex';
            }
        }

        // Nofollow
        if ( in_array( 'nofollow', $robots, true ) ) {
            $existing = get_post_meta( $post_id, '_tam_rank_nofollow', true );
            if ( empty( $existing ) ) {
                $backup['_tam_rank_nofollow'] = $existing;
                update_post_meta( $post_id, '_tam_rank_nofollow', 1 );
                $migrated[] = '_tam_rank_nofollow';
            }
        }

        if ( empty( $migrated ) ) {
            return null;
        }

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

    /**
     * Migrate Rank Math rich snippet → TamRank schema meta.
     *
     * Rank Math stores:
     *   - rank_math_rich_snippet          (article, product, event, faq, howto, video, …, or 'off')
     *   - rank_math_snippet_article_type  (Article, BlogPosting, NewsArticle)
     *
     * Primary (schema_snippet_map) → tamrank_schema_type
     * Extra   (schema_snippet_extras) → _tamrank_schema_auto_extras,
     *           primary derived from post_type.
     *
     * 'off', unknown, and unsupported types (book/music/person/profilepage/
     * webpage/website/collectionpage/PRO-only types) are skipped.
     *
     * Target meta:
     *   - tamrank_schema_type          : mapped primary type
     *   - tamrank_schema_enabled       : '1'
     *   - tamrank_schema_bron          : 'automatisch'
     *   - _tamrank_schema_auto_extras  : array (only when applicable)
     */
    private function migrate_schema( $post_id ) {
        $migrated = array();
        $backup   = array();

        $snippet = get_post_meta( $post_id, 'rank_math_rich_snippet', true );
        if ( empty( $snippet ) || $snippet === 'off' ) {
            return null;
        }

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

        $snippet_key  = strtolower( $snippet );
        $tamrank_type = '';
        $extras       = array();

        if ( $snippet_key === 'article' ) {
            $article_sub = get_post_meta( $post_id, 'rank_math_snippet_article_type', true );
            if ( ! empty( $article_sub ) && isset( $this->schema_article_subtype_map[ $article_sub ] ) ) {
                $tamrank_type = $this->schema_article_subtype_map[ $article_sub ];
            } else {
                $tamrank_type = 'BlogPosting';
            }
        } elseif ( isset( $this->schema_snippet_map[ $snippet_key ] ) ) {
            $tamrank_type = $this->schema_snippet_map[ $snippet_key ];
        } elseif ( isset( $this->schema_snippet_extras[ $snippet_key ] ) ) {
            $extras[]     = $this->schema_snippet_extras[ $snippet_key ];
            $tamrank_type = $this->default_primary_for_post( $post );
        }

        if ( empty( $tamrank_type ) ) {
            return null;
        }

        // Don't overwrite existing TamRank schema config.
        $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 null;
        }

        $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', $extras );
            $migrated[] = '_tamrank_schema_auto_extras';
        }

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

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

    /**
     * Transform Rank Math template variables to TamRank format.
     *
     * @param string $value
     * @return string
     */
    public function transform_variables( $value ) {
        foreach ( $this->variable_map as $rm_var => $tamrank_var ) {
            if ( strpos( $rm_var, '(' ) !== false ) {
                $value = preg_replace( '/' . $rm_var . '/', $tamrank_var, $value );
            } else {
                $value = str_replace( $rm_var, $tamrank_var, $value );
            }
        }

        $value = preg_replace( '/\s{2,}/', ' ', $value );
        return trim( $value );
    }

    /* ------------------------------------------------------------------
     * Redirects (Rank Math Redirections module)
     * ----------------------------------------------------------------*/

    /**
     * Collect importable redirects from Rank Math.
     *
     * Rank Math stores redirects in the {$wpdb->prefix}rank_math_redirections
     * table: `sources` is a serialized array of
     * array( 'pattern' => string, 'comparison' => 'exact'|'contains'|'start'|'end'|'regex' );
     * one row can hold multiple sources (each importable source becomes its
     * own TamRank redirect towards the same url_to). Only exact + active rows
     * with a status TamRank serves are importable; the rest is skipped.
     * The table only exists when the Redirections module was ever enabled.
     *
     * Rank Math clears url_to itself the moment a rule is set to 410 or 451,
     * so those rows arrive here with an empty target. TamRank serves both
     * statuses without one, so the target requirement is applied per status
     * instead of to every row — otherwise every Gone rule a Rank Math user
     * built would be dropped on the way over.
     *
     * @return array {
     *     @type array $import  List of array( 'source' => string, 'target' => string, 'type' => string ).
     *     @type int   $skipped Number of source patterns not importable.
     * }
     */
    public function get_redirects_to_import() {
        global $wpdb;

        $import  = array();
        $skipped = 0;

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

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- existence check for a third-party table during admin-context migration; caching not applicable
        $exists = $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $table ) );
        if ( $exists !== $table ) {
            return array( 'import' => array(), 'skipped' => 0 );
        }

        // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- read-only migration query on a third-party table; only the table identifier is interpolated, no user values; admin-context, caching not applicable
        $rows = $wpdb->get_results( "SELECT sources, url_to, header_code, status FROM {$table}" );
        // phpcs:enable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter

        if ( empty( $rows ) ) {
            return array( 'import' => array(), 'skipped' => 0 );
        }

        // Same list create_redirect() validates against, filter included, so a
        // status an add-on allows migrates instead of being silently dropped.
        $allowed_types = apply_filters( 'tamrank_redirect_types', array( '301', '302', '307', '410', '451' ) );

        foreach ( $rows as $row ) {
            // Deliberately NOT maybe_unserialize(): core implements that as a
            // bare @unserialize(), so a serialised object in this third-party
            // column would be instantiated and its __wakeup()/__destruct() would
            // run. The is_array() check below cannot prevent that — it only sees
            // the result, long after the magic methods fired. allowed_classes
            // => false turns any object into __PHP_Incomplete_Class without
            // constructing it. The is_serialized() guard keeps the rest of the
            // behaviour identical to maybe_unserialize(): a plain (unserialised)
            // value is passed through untouched.
            $sources = $row->sources;
            if ( is_serialized( $sources ) ) {
                // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize -- third-party data; object instantiation is explicitly disabled via allowed_classes
                $sources = @unserialize( trim( $sources ), array( 'allowed_classes' => false ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- malformed third-party payload must not emit a notice; the is_array() check below rejects it
            }
            if ( ! is_array( $sources ) ) {
                $skipped++;
                continue;
            }

            $type         = (string) (int) $row->header_code;
            $target       = (string) $row->url_to;
            $needs_target = self::status_needs_target( $type );

            // The server never reads the target of a 410 or a 451, so a leftover
            // url_to on such a row is noise in the redirect table, not data.
            if ( ! $needs_target ) {
                $target = '';
            }

            $row_usable = ( $row->status === 'active' )
                && in_array( $type, $allowed_types, true )
                && ( ! $needs_target || $target !== '' );

            foreach ( $sources as $source ) {
                if ( ! is_array( $source ) || empty( $source['pattern'] ) ) {
                    $skipped++;
                    continue;
                }
                $comparison = isset( $source['comparison'] ) ? $source['comparison'] : 'exact';
                if ( ! $row_usable || $comparison !== 'exact' ) {
                    $skipped++;
                    continue;
                }
                $import[] = array(
                    'source' => (string) $source['pattern'],
                    'target' => $target,
                    'type'   => $type,
                );
            }
        }

        return array( 'import' => $import, 'skipped' => $skipped );
    }

    /**
     * Does a rule with this status code need a target URL?
     *
     * Asks TamRank_Redirect_Manager so the import cannot drift apart from the
     * form, the AJAX handler and the CSV import. The inline fallback mirrors
     * that method exactly and only exists so a missing class is never a fatal
     * — same defensive seam the scanner uses around count_creatable_redirects().
     *
     * @param string|int $status Status code.
     * @return bool True when a target URL is mandatory.
     */
    private static function status_needs_target( $status ) {
        if ( class_exists( 'TamRank_Redirect_Manager' ) ) {
            return TamRank_Redirect_Manager::status_needs_target( $status );
        }

        $code = (int) $status;

        return ( $code >= 300 && $code <= 399 );
    }
}
