<?php
/**
 * TamRank Migration Manager
 *
 * Orchestrates batch migration from Yoast/Rank Math to TamRank.
 * Processes 50 posts per AJAX call, tracks state in options.
 */

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

class TamRank_Migration_Manager {

    /** @var int Max posts per batch */
    const BATCH_SIZE = 50;

    /** @var string Option key for migration state */
    const STATE_OPTION = 'tamrank_migration_state';

    public function __construct() {
        // Load dependencies
        if ( ! class_exists( 'TamRank_SEO_Scanner' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-seo-scanner.php';
        }
        if ( ! class_exists( 'TamRank_Yoast_Migrator' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-yoast-migrator.php';
        }
        if ( ! class_exists( 'TamRank_RankMath_Migrator' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-rankmath-migrator.php';
        }
        if ( ! class_exists( 'TamRank_AIOSEO_Migrator' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-aioseo-migrator.php';
        }
        if ( ! class_exists( 'TamRank_Option_Backup' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-option-backup.php';
        }
        if ( ! class_exists( 'TamRank_Settings_Migrator' ) ) {
            require_once TAM_RANK_PLUGIN_PATH . 'includes/onboarding/class-tamrank-settings-migrator.php';
        }
    }

    /**
     * Run a single batch of migration.
     *
     * @param array       $categories  Selected categories to migrate
     * @param int         $offset      Current offset
     * @param string|null $source      Explicit source plugin chosen in the
     *                                 wizard ('yoast'|'rankmath'|'aioseo');
     *                                 null = legacy auto-detect.
     * @param array       $options     Wizard choices that are not categories, currently
     *                                 'robots_conflict' => 'keep'|'replace'.
     * @return array {
     *     @type int    $processed     Items processed in this batch
     *     @type int    $next_offset   Offset for next batch
     *     @type bool   $done          Whether migration is complete
     *     @type array  $errors        Errors encountered in this batch
     *     @type array  $notes         Plain-language report lines for this batch
     *     @type int    $written       Values actually written in this batch
     *     @type int    $skipped_items Source items deliberately left behind
     * }
     */
    public function run_batch( $categories, $offset = 0, $source = null, $options = array() ) {
        // Prevent PHP timeout
        if ( function_exists( 'set_time_limit' ) ) {
            @set_time_limit( 0 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- best-effort execution-time raise for batch processing; degrades gracefully if disabled
        }

        if ( is_string( $source ) && $source !== '' ) {
            // Explicit source from the wizard: never re-detect (re-detection
            // per batch could silently flip the source mid-migration).
            $plugin = $source;
        } else {
            // Legacy path: detect source plugin (bit-for-bit the old behaviour).
            $scanner   = new TamRank_SEO_Scanner();
            $detection = $scanner->detect_seo_plugin();

            if ( ! $detection['plugin'] ) {
                return array(
                    'processed'     => 0,
                    'next_offset'   => 0,
                    'done'          => true,
                    'errors'        => array(),
                    'notes'         => array(),
                    'written'       => 0,
                    'skipped_items' => 0,
                );
            }
            $plugin = $detection['plugin'];
        }

        // Get migrator
        $migrator = $this->get_migrator( $plugin );
        if ( ! $migrator ) {
            return array(
                'processed'     => 0,
                'next_offset'   => 0,
                'done'          => true,
                // translators: %s is the detected (but unsupported) SEO plugin identifier.
                'errors'        => array( sprintf( __('Unknown SEO plugin: %s', 'tam-rank'), $plugin ) ),
                'notes'         => array(),
                'written'       => 0,
                'skipped_items' => 0,
            );
        }

        // Back up the site-wide options this migration may touch, before anything is
        // written - and only those. Capturing an option whose category is not selected
        // would let a later rollback silently reset a robots.txt or sitemap
        // configuration the user changed himself after the migration. Idempotent per
        // option: an entry from an earlier run is kept, never replaced, and a later
        // migration that adds a new category captures that option at that moment.
        if ( $offset === 0 ) {
            TamRank_Option_Backup::capture(
                TamRank_Option_Backup::options_for_categories( $categories )
            );
        }

        // Redirects are not post-based: import them once, at the start of
        // the first batch, before the post loop. The category key itself is
        // harmless further down (migrate_post/get_source_keys ignore it).
        $redirect_processed = 0;
        $redirect_errors    = array();
        $redirect_notes     = array();
        $redirect_skipped   = 0;
        if ( $offset === 0 && in_array( 'redirects', $categories, true ) ) {
            $redirect_result    = $this->migrate_redirects( $migrator );
            $redirect_processed = $redirect_result['imported'];
            $redirect_errors    = $redirect_result['errors'];
            $redirect_notes     = $redirect_result['notes'];
            $redirect_skipped   = $redirect_result['skipped'];
        }

        // robots.txt and sitemap configuration are site-wide too: import them once, on
        // the first batch. Both categories are optional and independent of each other.
        $settings_processed = 0;
        $settings_changed   = 0;
        $settings_errors    = array();
        $settings_notes     = array();
        if ( $offset === 0 ) {
            $settings_migrator  = new TamRank_Settings_Migrator( $plugin );
            $settings_result    = $settings_migrator->run( $categories, (array) $options );
            $settings_processed = $settings_result['imported'];
            $settings_changed   = $settings_result['changed'];
            $settings_errors    = $settings_result['errors'];
            $settings_notes     = $settings_result['notes'];
        }

        // What this batch really put on disk. Separate from $processed on
        // purpose: $processed counts items handled (a post that turned out to
        // have nothing new counts, and should - it is progress), while $written
        // counts values that were not there before. A second migration on the
        // same site handles everything again and writes nothing, and only
        // $written can tell the report which of the two happened.
        $written = $redirect_processed + $settings_changed;

        // Get posts with source data
        $post_ids = $this->get_posts_with_data( $migrator, $categories, $offset );

        if ( empty( $post_ids ) ) {
            $this->save_state( 'completed', $categories, $offset, $plugin );
            return array(
                'processed'     => $redirect_processed + $settings_processed,
                'next_offset'   => $offset,
                'done'          => true,
                'errors'        => array_merge( $redirect_errors, $settings_errors ),
                'notes'         => array_merge( $settings_notes, $redirect_notes ),
                'written'       => $written,
                'skipped_items' => $redirect_skipped,
            );
        }

        // Save state as in_progress
        $this->save_state( 'in_progress', $categories, $offset, $plugin );

        // Process batch
        $processed = $redirect_processed + $settings_processed;
        $errors    = array_merge( $redirect_errors, $settings_errors );

        foreach ( $post_ids as $post_id ) {
            try {
                $result = $migrator->migrate_post( $post_id, $categories );
                if ( $result['success'] ) {
                    $processed++;
                }
                // Every migrator fills 'migrated' with the target keys it actually
                // wrote, and leaves it empty when TamRank already had the value.
                if ( ! empty( $result['migrated'] ) ) {
                    $written += count( $result['migrated'] );
                }
            } catch ( \Exception $e ) {
                $errors[] = sprintf(
                    'Post %d (%s): %s',
                    $post_id,
                    get_the_title( $post_id ),
                    $e->getMessage()
                );
            }
        }

        $next_offset = $offset + self::BATCH_SIZE;
        $done = count( $post_ids ) < self::BATCH_SIZE;

        if ( $done ) {
            $this->save_state( 'completed', $categories, $next_offset, $plugin );
        } else {
            $this->save_state( 'in_progress', $categories, $next_offset, $plugin );
        }

        return array(
            'processed'     => $processed,
            'next_offset'   => $next_offset,
            'done'          => $done,
            'errors'        => $errors,
            'notes'         => array_merge( $settings_notes, $redirect_notes ),
            'written'       => $written,
            'skipped_items' => $redirect_skipped,
        );
    }

    /**
     * Get the appropriate migrator instance.
     */
    private function get_migrator( $plugin ) {
        switch ( $plugin ) {
            case 'yoast':
                return new TamRank_Yoast_Migrator();
            case 'rankmath':
                return new TamRank_RankMath_Migrator();
            case 'aioseo':
                return new TamRank_AIOSEO_Migrator();
            default:
                return null;
        }
    }

    /**
     * Get post IDs that have source SEO data.
     */
    private function get_posts_with_data( $migrator, $categories, $offset ) {
        global $wpdb;

        // Table-backed sources (AIOSEO) select their own post IDs; the
        // method_exists seam mirrors the get_redirects_to_import precedent.
        if ( method_exists( $migrator, 'get_post_ids' ) ) {
            return $migrator->get_post_ids( $categories, $offset, self::BATCH_SIZE );
        }

        $source_keys = $migrator->get_all_source_keys( $categories );
        if ( empty( $source_keys ) ) {
            return array();
        }

        $placeholders = implode( ', ', array_fill( 0, count( $source_keys ), '%s' ) );
        $args = $source_keys;
        $args[] = $offset;
        $args[] = self::BATCH_SIZE;

        // phpcs:disable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter -- only table identifiers + a runtime-built %s,%s IN() placeholder string are interpolated; all user values use prepare() placeholders and the placeholder count matches the $args array; direct read on core postmeta/posts in admin migration context, caching not applicable
        return $wpdb->get_col( $wpdb->prepare(
            "SELECT DISTINCT pm.post_id
             FROM {$wpdb->postmeta} pm
             INNER JOIN {$wpdb->posts} p ON pm.post_id = p.ID
             WHERE pm.meta_key IN ($placeholders)
             AND pm.meta_value != ''
             AND p.post_status IN ('publish', 'draft', 'pending', 'future', 'private')
             ORDER BY pm.post_id ASC
             LIMIT %d, %d",
            ...$args
        ) );
        // phpcs:enable WordPress.DB.PreparedSQL, WordPress.DB.PreparedSQLPlaceholders, WordPress.DB.DirectDatabaseQuery, PluginCheck.Security.DirectDB.UnescapedDBParameter
    }

    /**
     * Save migration state to options.
     *
     * The source key is an audit trail of which plugin actually ran;
     * nothing reads this option except the rollback's delete_option.
     */
    private function save_state( $status, $categories, $offset, $source = null ) {
        update_option( self::STATE_OPTION, array(
            'status'     => $status,
            'categories' => $categories,
            'offset'     => $offset,
            'source'     => $source,
            'updated_at' => current_time( 'mysql' ),
        ) );
    }

    /**
     * Import redirects from the source plugin into tam_rank_redirects.
     *
     * Runs once per migration (first batch). Reuses
     * TamRank_Redirect_Manager::create_redirect() so normalisation,
     * duplicate protection (UNIQUE source_url) and type validation are
     * identical to manually created redirects. Every created row ID is
     * recorded in the tamrank_migration_redirect_ids option so rollback
     * can delete exactly what the migration created — and nothing else.
     *
     * @param TamRank_Yoast_Migrator|TamRank_RankMath_Migrator $migrator
     * @return array { @type int $imported, @type array $errors, @type array $notes, @type int $skipped }
     */
    private function migrate_redirects( $migrator ) {
        if ( ! method_exists( $migrator, 'get_redirects_to_import' ) ) {
            return array( 'imported' => 0, 'errors' => array(), 'notes' => array(), 'skipped' => 0 );
        }

        global $wpdb, $tam_rank_redirect_manager;
        if ( ! ( $tam_rank_redirect_manager instanceof TamRank_Redirect_Manager ) ) {
            return array(
                'imported' => 0,
                'errors'   => array( __( 'Redirects: redirect manager unavailable, category skipped.', 'tam-rank' ) ),
                'notes'    => array(),
                'skipped'  => 0,
            );
        }

        $result   = $migrator->get_redirects_to_import();
        $imported = 0;
        $skipped  = $result['skipped'];
        $ids      = get_option( 'tamrank_migration_redirect_ids', array() );
        if ( ! is_array( $ids ) ) {
            $ids = array();
        }

        foreach ( $result['import'] as $redirect ) {
            $created = $tam_rank_redirect_manager->create_redirect(
                $redirect['source'],
                $redirect['target'],
                $redirect['type']
            );

            if ( is_wp_error( $created ) ) {
                // 'redirect_exists' = the safety promise (existing TamRank
                // data is never overwritten) — count as skipped, not error.
                $skipped++;
                continue;
            }

            $ids[] = (int) $created;
            $imported++;

            // Stamp the provenance AFTER the row exists, deliberately: this
            // must not touch how the row is created. create_redirect() is
            // called with exactly the arguments it was called with before, so
            // normalisation, type validation and the UNIQUE source_url skip
            // behave identically — an imported redirect is still refused when
            // the user already has a rule for that source.
            //
            // Without this the row falls back on the column default 'manual'
            // and the Redirect Manager's Auto/Manual filter put a wizard import
            // on the same heap as something the user typed. Rollback always
            // knew better (it reads tamrank_migration_redirect_ids), so the
            // screen was the only place where the two were indistinguishable.
            //
            // Same shape as the suggestions path (class-redirect-suggestions.php:
            // create, then stamp), keyed on the returned id rather than the
            // source URL. A failing update means the created_by column is not
            // there yet on this install; the row is correct either way and the
            // filter falls back on the old two buckets.
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery -- write operation on a custom plugin table (tam_rank_redirects); admin-context update, caching not applicable.
            $wpdb->update(
                $wpdb->prefix . 'tam_rank_redirects',
                array( 'created_by' => 'import' ),
                array( 'id' => (int) $created ),
                array( '%s' ),
                array( '%d' )
            );
        }

        if ( ! empty( $ids ) ) {
            update_option( 'tamrank_migration_redirect_ids', $ids, false );
        }

        // A skipped redirect is news, not a failure: a regex rule TamRank cannot
        // express, a 410, or a source the user already has a rule for. Putting
        // that line in errors[] turned a run in which nothing went wrong into
        // "Migration complete with remarks" and cost it its zero-errors badge.
        // It belongs in the report, next to the robots.txt and sitemap lines.
        $notes = array();
        if ( $skipped > 0 ) {
            $notes[] = sprintf(
                // translators: %1$d = imported redirects, %2$d = skipped entries (regex/duplicate/unsupported).
                __( 'Redirects: %1$d imported, %2$d skipped (regex, duplicate or unsupported type).', 'tam-rank' ),
                $imported,
                $skipped
            );
        }

        return array(
            'imported' => $imported,
            'errors'   => array(),
            'notes'    => $notes,
            'skipped'  => $skipped,
        );
    }

}
