<?php
/**
 * Takes over the data left behind by the SEO Forge name.
 *
 * The plugin was renamed to Rank Forge in 1.1.0. Every persistence key carried
 * the old name — three tables, the options, and the per-post meta — so without
 * this the renamed plugin would find no `rankforge_db_version`, conclude it was
 * a fresh install, create empty tables beside the full ones, and silently
 * abandon every redirect, every stored analysis and the SEO meta on every post.
 * That is the whole reason this class exists, and why it has to run before
 * RANKFORGE_Schema_Manager::maybe_upgrade rather than alongside it.
 *
 * Everything here is idempotent and each step re-checks its own precondition,
 * so an interrupted run (a fatal, a timeout on a large postmeta table) resumes
 * correctly on the next request instead of half-applying.
 */

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

class RANKFORGE_Rename_Migration {

    /** Set only once every step has completed. */
    const DONE_OPTION = 'rankforge_rename_migrated';

    const LEGACY  = 'seoforge';
    const CURRENT = 'rankforge';

    /** Suffixes of the tables the plugin owns, without the wpdb prefix. */
    const TABLES = ['analysis', 'redirects', 'breadcrumbs'];

    public static function maybe_run() {
        if (get_option(self::DONE_OPTION)) {
            return;
        }

        if (!self::has_legacy_data()) {
            // Fresh install: nothing to take over, and marking it done keeps
            // the detection queries off every subsequent request.
            update_option(self::DONE_OPTION, '1', true);
            return;
        }

        self::rename_tables();
        self::rename_options();
        self::rename_post_meta();
        self::drop_legacy_transients();
        self::clear_legacy_cron();

        update_option(self::DONE_OPTION, '1', true);
    }

    /**
 * A site is "legacy" if either an old table or an old option survives.
 * Both are checked because a site may have had its tables dropped by a
 * partial uninstall while its options remain, and vice versa.
 */
    private static function has_legacy_data() {
        global $wpdb;

        foreach (self::TABLES as $suffix) {
            $table = $wpdb->prefix . self::LEGACY . '_' . $suffix;
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared
            if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table)) === $table) {
                return true;
            }
        }

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $option = $wpdb->get_var($wpdb->prepare(
            "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s LIMIT 1",
            $wpdb->esc_like(self::LEGACY . '_') . '%'
        ));

        return (bool) $option;
    }

    private static function rename_tables() {
        global $wpdb;

        foreach (self::TABLES as $suffix) {
            $from = $wpdb->prefix . self::LEGACY . '_' . $suffix;
            $to   = $wpdb->prefix . self::CURRENT . '_' . $suffix;

            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $has_from = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $from)) === $from;
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $has_to   = $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $to)) === $to;

            // Only rename into an empty slot. If both exist the new table was
            // created by a run that got as far as Schema_Manager, and moving
            // the old one over it would destroy whichever holds the real rows.
            if ($has_from && !$has_to) {
                // Identifiers cannot be parameterised; both names are built
                // from the wpdb prefix and a constant, never from input.
                // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.NotPrepared, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                $wpdb->query("RENAME TABLE `{$from}` TO `{$to}`");
            }
        }
    }

    /**
 * Options are moved with a single UPDATE rather than a read-modify-write
 * loop: there are over a hundred of them, and autoloaded values would
 * otherwise be pulled into memory for no reason.
 *
 * IGNORE skips a row whose target name already exists, which happens when
 * the renamed plugin ran once and wrote a default before this migration
 * got its turn. The freshly written default loses to nothing — the legacy
 * row simply stays behind and is dropped below.
 */
    private static function rename_options() {
        global $wpdb;

        $like = $wpdb->esc_like(self::LEGACY . '_') . '%';
        $cut  = strlen(self::LEGACY);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query($wpdb->prepare(
            "UPDATE IGNORE {$wpdb->options}
                SET option_name = CONCAT(%s, SUBSTRING(option_name, %d))
              WHERE option_name LIKE %s",
            self::CURRENT,
            $cut + 1,
            $like
        ));

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
            $like
        ));

        wp_cache_flush();
    }

    /** Post meta keys are prefixed with an underscore to keep them hidden. */
    private static function rename_post_meta() {
        global $wpdb;

        $like = $wpdb->esc_like('_' . self::LEGACY . '_') . '%';
        $cut  = strlen('_' . self::LEGACY);

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query($wpdb->prepare(
            "UPDATE IGNORE {$wpdb->postmeta}
                SET meta_key = CONCAT(%s, SUBSTRING(meta_key, %d))
              WHERE meta_key LIKE %s",
            '_' . self::CURRENT,
            $cut + 1,
            $like
        ));

        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
        $wpdb->query($wpdb->prepare(
            "DELETE FROM {$wpdb->postmeta} WHERE meta_key LIKE %s",
            $like
        ));

        wp_cache_flush();
    }

    /**
 * Transients are caches, so they are dropped rather than carried over —
 * they regenerate on demand, and their names are not worth migrating.
 */
    private static function drop_legacy_transients() {
        global $wpdb;

        foreach (['_transient_', '_transient_timeout_', '_site_transient_', '_site_transient_timeout_'] as $prefix) {
            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
            $wpdb->query($wpdb->prepare(
                "DELETE FROM {$wpdb->options} WHERE option_name LIKE %s",
                $wpdb->esc_like($prefix . self::LEGACY) . '%'
            ));
        }
    }

    /**
 * Events scheduled under the old hook names would sit in cron forever with
 * nothing listening, so they are unscheduled. The renamed plugin registers
 * its own schedules on the next load.
 */
    private static function clear_legacy_cron() {
        $crons = _get_cron_array();
        if (!is_array($crons)) {
            return;
        }

        foreach ($crons as $timestamp => $hooks) {
            foreach ($hooks as $hook => $events) {
                if (strpos($hook, self::LEGACY . '_') !== 0) {
                    continue;
                }
                foreach ($events as $event) {
                    wp_unschedule_event($timestamp, $hook, $event['args']);
                }
            }
        }
    }
}
