<?php
/**
 * TamRank Migration Option Backup
 *
 * The migration wizard writes a handful of site-wide options (robots.txt body,
 * sitemap settings, schema/site identity). Those are not post meta, so the per-post _tamrank_migration_backup
 * mechanism does not cover them: that one is per row and scales with the batch, while
 * these are site-wide and there are only a handful. One option, written once, is
 * simpler and safer than forcing the per-post pattern onto them.
 *
 * Two rules are shared with the post-meta backup:
 *  - the backup is written BEFORE the first change;
 *  - a backup from an earlier run is never overwritten.
 *
 * The backup is category-aware: only the options belonging to the categories the user
 * actually selected are captured, and the restore only touches what was captured.
 * Capturing everything would let a rollback of a meta-only migration silently reset a
 * robots.txt or sitemap configuration the user changed himself afterwards.
 */

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

class TamRank_Option_Backup {

    /** @var string Option holding the backup. */
    const OPTION = 'tamrank_migration_option_backup';

    /** @var int Payload version, for future format changes. */
    const VERSION = 1;

    /** @var string Marks "this option did not exist" - see capture(). */
    const ABSENT_SENTINEL = '__tamrank_option_absent__';

    /**
     * Which migration category writes which site-wide option.
     *
     * Keys mirror TamRank_Settings_Migrator::CATEGORY_ROBOTS / CATEGORY_SITEMAP; they
     * are written out literally so this class stays loadable on its own (the settings
     * page loads it without the migrator).
     *
     * @return array<string,string> category key => option name.
     */
    public static function category_option_map() {
        return array(
            'robots_txt'     => 'tam_rank_robots_txt',
            'sitemap_config' => 'tam_rank_sitemap_settings',
            // The site identity lands in the schema settings option the Schema
            // tab already owns. Backing the whole option up is right, not
            // heavy-handed: it is one row, and restoring it puts the schema
            // settings back exactly as they were before the migration,
            // including the fields the migration deliberately did not
            // overwrite (restoring those to their own value is a no-op).
            'site_identity'  => 'tamrank_schema_global_settings',
            // The schema category also adopts the source plugin's per-post-type
            // default (Yoast's schema-article-type-<pt>). That is one site-wide
            // row, not post meta, so it needs to be here or a rollback would
            // leave it behind.
            'schema'         => 'tamrank_schema_default_types',
        );
    }

    /**
     * The options a migration of these categories may write.
     *
     * @param array $categories Selected category keys.
     * @return string[] Option names, empty when no settings category was selected.
     */
    public static function options_for_categories( $categories ) {
        $map   = self::category_option_map();
        $names = array();

        foreach ( (array) $categories as $category ) {
            if ( is_string( $category ) && isset( $map[ $category ] ) ) {
                $names[] = $map[ $category ];
            }
        }

        return array_values( array_unique( $names ) );
    }

    /**
     * Options the migration may write, and therefore has to back up.
     *
     * @return string[]
     */
    public static function covered_options() {
        return array_values( self::category_option_map() );
    }

    /**
     * Option names held in the backup, in capture order.
     *
     * @return string[]
     */
    public static function captured_option_names() {
        $backup = get_option( self::OPTION, array() );

        if ( ! is_array( $backup ) || empty( $backup['options'] ) || ! is_array( $backup['options'] ) ) {
            return array();
        }

        return array_values( array_filter( array_keys( $backup['options'] ), 'is_string' ) );
    }

    /**
     * Capture the current value of the given options, if not already captured.
     *
     * Records 'existed' explicitly: get_option() returns false both for "missing" and
     * for "present but false/empty", and only a unique sentinel tells those apart. The
     * restore needs that difference to choose between update_option and delete_option.
     *
     * "Never overwrite" applies per option, not to the backup as a whole: a second
     * migration that writes the same category leaves that option's existing snapshot
     * alone, while a later migration that writes a category nobody captured yet adds
     * that option to the same backup at that moment.
     *
     * @param string[]|null $option_names Defaults to covered_options().
     * @return bool True when at least one option was added, false when there was
     *              nothing new to record.
     */
    public static function capture( $option_names = null ) {
        $option_names = is_array( $option_names ) ? $option_names : self::covered_options();
        $option_names = array_values( array_unique( array_filter( $option_names, 'is_string' ) ) );

        if ( empty( $option_names ) ) {
            return false; // Nothing this migration can write, so nothing to back up.
        }

        $backup  = get_option( self::OPTION, array() );
        $backup  = is_array( $backup ) ? $backup : array();
        $entries = ( ! empty( $backup['options'] ) && is_array( $backup['options'] ) )
            ? $backup['options']
            : array();

        $added = 0;

        foreach ( $option_names as $name ) {
            if ( '' === $name || array_key_exists( $name, $entries ) ) {
                continue; // Never overwrite what an earlier run recorded.
            }

            $value = get_option( $name, self::ABSENT_SENTINEL );
            if ( self::ABSENT_SENTINEL === $value ) {
                $entries[ $name ] = array( 'existed' => false, 'value' => null );
            } else {
                $entries[ $name ] = array( 'existed' => true, 'value' => $value );
            }
            $added++;
        }

        if ( 0 === $added ) {
            return false;
        }

        update_option(
            self::OPTION,
            array(
                'version'    => self::VERSION,
                // Keep the moment the first snapshot was taken, not the moment a later
                // category was added: that first moment is the pre-migration state.
                'created_at' => ! empty( $backup['created_at'] ) ? $backup['created_at'] : current_time( 'mysql' ),
                'options'    => $entries,
            ),
            false
        );

        return true;
    }

    /**
     * Whether an option backup is present.
     *
     * Uses a sentinel rather than !== false, so an empty payload still counts as
     * present instead of silently reading as "no backup".
     *
     * @return bool
     */
    public static function exists() {
        return self::ABSENT_SENTINEL !== get_option( self::OPTION, self::ABSENT_SENTINEL );
    }

    /**
     * Number of options held in the backup.
     *
     * @return int
     */
    public static function count() {
        return count( self::captured_option_names() );
    }

    /**
     * Restore every backed-up option to its pre-migration state and drop the backup.
     *
     * Only options that are actually in the backup are touched. Everything else - a
     * robots.txt or sitemap configuration this migration never wrote - is left exactly
     * as the user left it.
     *
     * Writes through tam_rank_write_option_raw() so the register_setting sanitizer does
     * not reshape the restored value. Without that, restoring the normal pre-migration
     * shape of tam_rank_sitemap_settings (the flat activation form, no content_types
     * key) would come back out of the whitelist as content_types with every type set to
     * an explicit false: an empty sitemap and a silent IndexNow, as the result of a
     * rollback.
     *
     * @return int Number of options restored or deleted.
     */
    public static function restore() {
        $backup = get_option( self::OPTION, array() );
        if ( ! is_array( $backup ) || empty( $backup['options'] ) || ! is_array( $backup['options'] ) ) {
            delete_option( self::OPTION );
            return 0;
        }

        $restored = 0;

        foreach ( $backup['options'] as $name => $entry ) {
            if ( ! is_string( $name ) || $name === '' ) {
                continue;
            }
            if ( ! empty( $entry['existed'] ) ) {
                tam_rank_write_option_raw( $name, $entry['value'] );
            } else {
                delete_option( $name );
                tam_rank_cached_option_invalidate( $name );
            }
            $restored++;
        }

        // The sitemap serves from transients; without this flush the migrated sitemap
        // keeps being served until the transient expires.
        tam_rank_flush_sitemap_transients();

        delete_option( self::OPTION );

        return $restored;
    }

}
