<?php
/**
 * Schema Architecture V2 versioned migration framework.
 *
 * Provides locking, checksummed backup, dry-run classification, sanitized
 * reports, and global specialized transforms.
 *
 * Separate from the legacy {@see SchemaDefaultsMigration} marker.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

use SEOvaultSEO\Schema\Readiness\SchemaReadinessEvaluator;
use SEOvaultSEO\Schema\Validation\SchemaValidationCache;

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

/**
 * Versioned V2 schema migration helpers (backup / lock / dry-run / global transform).
 */
class SchemaMigration {

	/**
	 * Target migration generation after global + per-post batches.
	 */
	const TARGET_MIGRATION_VERSION = 2;

	/**
	 * Version recorded after Phase 10 global specialized transform succeeds.
	 */
	const VERSION_AFTER_GLOBAL = 1;

	/**
	 * Short lock TTL in seconds.
	 */
	const LOCK_TTL = 120;

	/**
	 * Post meta: original global schemas option key.
	 */
	const META_MIGRATION_SOURCE_ID = '_seovault_schema_migration_source_id';

	/**
	 * Post meta: stable migration identifier (idempotent upserts).
	 */
	const META_MIGRATION_ID = '_seovault_schema_migration_id';

	/**
	 * Option: completed V2 migration version (unset/0 until Phase 10+ succeeds).
	 */
	const OPTION_MIGRATION_VERSION = 'schema_v2_migration_version';

	/**
	 * Option: checksummed backup of pre-migration configuration.
	 */
	const OPTION_BACKUP = 'schema_v2_migration_backup';

	/**
	 * Option: short-lived migration lock payload.
	 */
	const OPTION_LOCK = 'schema_v2_migration_lock';

	/**
	 * Option: last sanitized dry-run / migration report (no secrets / raw bodies).
	 */
	const OPTION_REPORT = 'schema_v2_migration_report';

	/**
	 * Option: one-time admin notice / restore-window state (autoload disabled).
	 */
	const OPTION_NOTICE = 'schema_v2_migration_notice';

	/**
	 * Option: pause automatic Upgrade-path remigration after a successful restore.
	 *
	 * When set, {@see is_needed()} remains true (version cleared) but
	 * {@see should_auto_run()} is false until an explicit remigrate clears the pause.
	 */
	const OPTION_AUTO_MIGRATION_PAUSED = 'schema_v2_auto_migration_paused';

	/**
	 * Notice status: awaiting admin review.
	 */
	const NOTICE_PENDING = 'pending';

	/**
	 * Notice status: dismissed (backup retained).
	 */
	const NOTICE_DISMISSED = 'dismissed';

	/**
	 * Notice status: legacy configuration restored from backup.
	 */
	const NOTICE_RESTORED = 'restored';

	/**
	 * Classification: foundational / already-safe automatic context types.
	 */
	const CLASS_SAFE_AUTOMATIC = 'safe_automatic';

	/**
	 * Classification: specialized + broad targeting + no meaningful customization.
	 */
	const CLASS_EMPTY_SPECIALIZED_BROAD = 'empty_specialized_broad';

	/**
	 * Classification: specialized + broad targeting + meaningful customization.
	 */
	const CLASS_MEANINGFUL_SPECIALIZED_BROAD = 'meaningful_specialized_broad';

	/**
	 * Classification: specialized + narrow targeting + readiness complete.
	 */
	const CLASS_COMPLETE_SPECIALIZED_NARROW = 'complete_specialized_narrow';

	/**
	 * Classification: specialized + narrow targeting + not ready.
	 */
	const CLASS_INCOMPLETE_SPECIALIZED_NARROW = 'incomplete_specialized_narrow';

	/**
	 * Classification: type unknown to built-in registry / extension entry.
	 */
	const CLASS_UNKNOWN_EXTENSION = 'unknown_extension';

	/**
	 * Rules treated as broad / site-wide specialized spray.
	 *
	 * @var string[]
	 */
	private static $broad_rules = array(
		'basic-global',
		'post|all',
		'post|post',
		'post|page',
	);

	/**
	 * Whether the versioned V2 migration still needs to complete.
	 *
	 * Independent of {@see SchemaDefaultsMigration::MARKER_OPTION}.
	 * Does not consider the post-restore auto-migration pause — use
	 * {@see should_auto_run()} for the automatic Upgrade path.
	 *
	 * @return bool
	 */
	public static function is_needed() {
		return self::get_migration_version() < self::TARGET_MIGRATION_VERSION;
	}

	/**
	 * Whether automatic upgrade should run Schema V2 migration this request.
	 *
	 * False after a successful restore until an admin explicitly re-runs migration.
	 *
	 * @return bool
	 */
	public static function should_auto_run() {
		return self::is_needed() && ! self::is_auto_migration_paused();
	}

	/**
	 * Whether automatic remigration is paused (post-restore hold).
	 *
	 * @return bool
	 */
	public static function is_auto_migration_paused() {
		return '1' === (string) get_option( 'seovaultseo_' . self::OPTION_AUTO_MIGRATION_PAUSED, '' );
	}

	/**
	 * Pause automatic Upgrade-path remigration (keeps restored legacy config).
	 *
	 * @return void
	 */
	public static function pause_auto_migration() {
		$option = 'seovaultseo_' . self::OPTION_AUTO_MIGRATION_PAUSED;
		if ( false === get_option( $option, false ) ) {
			add_option( $option, '1', '', false );
		} else {
			update_option( $option, '1', false );
		}
	}

	/**
	 * Clear the post-restore auto-migration pause.
	 *
	 * @return void
	 */
	public static function clear_auto_migration_pause() {
		delete_option( 'seovaultseo_' . self::OPTION_AUTO_MIGRATION_PAUSED );
	}

	/**
	 * Recorded completed migration version.
	 *
	 * @return int
	 */
	public static function get_migration_version() {
		return (int) get_option( 'seovaultseo_' . self::OPTION_MIGRATION_VERSION, 0 );
	}

	/**
	 * Apply Section 10.2 global specialized transforms.
	 *
	 * Idempotent: no-ops when migration version already meets the target.
	 * Does not set the completion version until all required writes succeed.
	 *
	 * @param array $args {
	 *     Optional arguments.
	 *
	 *     @type int $max_post_batches Max post batches this call (0 = until complete). Default 0.
	 * }
	 * @return array{
	 *   success:bool,
	 *   skipped:bool,
	 *   error:?string,
	 *   report:?array,
	 *   transformed:array<string,int>,
	 *   pending_posts?:bool,
	 *   post_migration?:array
	 * }
	 */
	public static function run( array $args = array() ) {
		/**
		 * Max post-migration batches this call. 0 = run until complete.
		 * Automatic Upgrade path passes 1 to avoid admin request timeouts.
		 *
		 * @var int
		 */
		$max_post_batches = isset( $args['max_post_batches'] ) ? max( 0, (int) $args['max_post_batches'] ) : 0;

		if ( ! self::is_needed() ) {
			return array(
				'success'     => true,
				'skipped'     => true,
				'error'       => null,
				'report'      => self::get_report(),
				'transformed' => array(),
			);
		}

		$result = array(
			'success'     => true,
			'skipped'     => false,
			'error'       => null,
			'report'      => null,
			'transformed' => array(),
		);

		if ( self::get_migration_version() < self::VERSION_AFTER_GLOBAL ) {
			$result = self::run_global_transform();
			if ( empty( $result['success'] ) ) {
				return $result;
			}
		}

		if ( self::get_migration_version() < self::TARGET_MIGRATION_VERSION ) {
			if ( ! SchemaPostMigration::can_run_in_current_context() ) {
				$result['pending_posts'] = true;
				$result['report']        = self::get_report();
				return $result;
			}

			$post_result = SchemaPostMigration::run( $max_post_batches );
			if ( ! empty( $post_result['complete'] ) ) {
				update_option(
					'seovaultseo_' . self::OPTION_MIGRATION_VERSION,
					self::TARGET_MIGRATION_VERSION,
					false
				);
			} else {
				$result['pending_posts'] = true;
			}
			$result['post_migration'] = $post_result;
			$result['report']         = self::get_report();
		}

		return $result;
	}

	/**
	 * Apply Section 10.2 global specialized transforms.
	 *
	 * Sets migration version to {@see VERSION_AFTER_GLOBAL} on success.
	 *
	 * @return array{
	 *   success:bool,
	 *   skipped:bool,
	 *   error:?string,
	 *   report:?array,
	 *   transformed:array<string,int>
	 * }
	 */
	public static function run_global_transform() {
		if ( self::get_migration_version() >= self::VERSION_AFTER_GLOBAL ) {
			return array(
				'success'     => true,
				'skipped'     => true,
				'error'       => null,
				'report'      => self::get_report(),
				'transformed' => array(),
			);
		}

		$token = self::acquire_lock();
		if ( ! $token ) {
			return array(
				'success'     => false,
				'skipped'     => false,
				'error'       => 'lock_contention',
				'report'      => null,
				'transformed' => array(),
			);
		}

		try {
			$schemas_raw = get_option( 'seovaultseo_schemas', false );
			$present     = false !== $schemas_raw;
			$schemas     = is_array( $schemas_raw ) ? $schemas_raw : array();

			$backup = self::get_backup();
			if ( ! self::verify_backup( $backup ) ) {
				$backup = self::write_backup( $schemas, $present );
				if ( is_wp_error( $backup ) ) {
					return array(
						'success'     => false,
						'skipped'     => false,
						'error'       => $backup->get_error_code(),
						'report'      => null,
						'transformed' => array(),
					);
				}
			}

			SchemaTemplate::register_post_type();

			$classified  = self::classify_schemas( $schemas );
			$new_schemas = array();
			$transformed = array(
				'kept'                        => 0,
				'retired'                     => 0,
				'inactive_drafts'             => 0,
				'active_templates'            => 0,
				'incomplete_drafts'           => 0,
				'extensions_preserved'        => 0,
				'retired_product_integration' => 0,
			);

			\SEOvaultSEO\Schema\Validation\SchemaValidationInvalidator::begin_quiet();
			try {
				foreach ( $classified as $entry ) {
					if ( ! is_array( $entry ) ) {
						continue;
					}

					$id     = (string) ( $entry['id'] ?? '' );
					$class  = (string) ( $entry['classification'] ?? '' );
					$schema = ( isset( $schemas[ $id ] ) && is_array( $schemas[ $id ] ) ) ? $schemas[ $id ] : null;

					switch ( $class ) {
						case self::CLASS_SAFE_AUTOMATIC:
							if ( is_array( $schema ) ) {
								$new_schemas[ $id ] = $schema;
							}
							++$transformed['kept'];
							break;

						case self::CLASS_EMPTY_SPECIALIZED_BROAD:
							++$transformed['retired'];
							break;

						case self::CLASS_MEANINGFUL_SPECIALIZED_BROAD:
							if ( is_array( $schema ) ) {
								self::upsert_migrated_template( $id, $schema, 'draft', $class );
							}
							++$transformed['inactive_drafts'];
							break;

						case self::CLASS_COMPLETE_SPECIALIZED_NARROW:
							if ( is_array( $schema ) && self::should_defer_product_to_integration( $schema ) ) {
								++$transformed['retired_product_integration'];
								break;
							}
							if ( is_array( $schema ) ) {
								self::upsert_migrated_template( $id, $schema, 'publish', $class );
							}
							++$transformed['active_templates'];
							break;

						case self::CLASS_INCOMPLETE_SPECIALIZED_NARROW:
							if ( is_array( $schema ) && self::should_defer_product_to_integration( $schema ) ) {
								++$transformed['retired_product_integration'];
								break;
							}
							if ( is_array( $schema ) ) {
								self::upsert_migrated_template( $id, $schema, 'draft', $class );
							}
							++$transformed['incomplete_drafts'];
							break;

						case self::CLASS_UNKNOWN_EXTENSION:
						default:
							if ( is_array( $schema ) ) {
								if ( self::is_broad_targeting( $schema ) ) {
									$schema['enabled'] = false;
								}
								$new_schemas[ $id ] = $schema;
							}
							++$transformed['extensions_preserved'];
							break;
					}
				}

				/**
				 * Allow tests to abort before completion writes.
				 *
				 * @param null|\WP_Error $abort Abort error or null.
				 * @param array          $ctx   Context.
				 */
				$abort = apply_filters(
					'seovaultseo_schema_v2_migration_abort_before_complete',
					null,
					array(
						'new_schemas' => $new_schemas,
						'transformed' => $transformed,
					)
				);
				if ( is_wp_error( $abort ) ) {
					return array(
						'success'     => false,
						'skipped'     => false,
						'error'       => $abort->get_error_code(),
						'report'      => null,
						'transformed' => $transformed,
					);
				}

				if ( $present || ! empty( $new_schemas ) ) {
					update_option( 'seovaultseo_schemas', $new_schemas, false );
				} elseif ( false !== $schemas_raw ) {
					delete_option( 'seovaultseo_schemas' );
				}

				if ( SchemaArchitectureSettings::get_architecture_version() < SchemaArchitectureSettings::ARCHITECTURE_VERSION ) {
					update_option(
						'seovaultseo_' . SchemaArchitectureSettings::OPTION_ARCHITECTURE_VERSION,
						SchemaArchitectureSettings::ARCHITECTURE_VERSION,
						false
					);
				}

				// Single generation bump after successful global transform.
				SchemaValidationCache::bump_generation();

				update_option(
					'seovaultseo_' . self::OPTION_MIGRATION_VERSION,
					self::VERSION_AFTER_GLOBAL,
					false
				);
			} finally {
				\SEOvaultSEO\Schema\Validation\SchemaValidationInvalidator::end_quiet();
			}

			$backup_meta = array(
				'written'  => true,
				'checksum' => is_array( $backup ) ? (string) ( $backup['checksum'] ?? '' ) : '',
				'verified' => self::verify_backup( $backup ),
			);
			$report                              = self::build_report( $classified, $present, $backup_meta );
			$report['production_transformed']    = true;
			$report['transformed']               = $transformed;
			$report['current_migration_version'] = self::get_migration_version();
			$report['migration_needed']          = self::is_needed();

			update_option( 'seovaultseo_' . self::OPTION_REPORT, $report, false );
			self::mark_notice_pending( true );
			self::clear_auto_migration_pause();

			return array(
				'success'     => true,
				'skipped'     => false,
				'error'       => null,
				'report'      => $report,
				'transformed' => $transformed,
			);
		} finally {
			self::release_lock( $token );
		}
	}

	/**
	 * Stable migration ID derived from the original schemas option key.
	 *
	 * @param string $source_id Original option entry key.
	 * @return string
	 */
	public static function stable_migration_id( $source_id ) {
		return 'sv2-mig-' . self::VERSION_AFTER_GLOBAL . '-' . substr( hash( 'sha256', (string) $source_id ), 0, 16 );
	}

	/**
	 * Convert legacy show_on rules into DisplayConditions includes.
	 *
	 * @param array $show_on Show-on payload.
	 * @return array
	 */
	public static function show_on_to_display_conditions( array $show_on ) {
		$conditions = array();
		$specific   = isset( $show_on['specific'] ) && is_array( $show_on['specific'] )
			? array_values( array_filter( array_map( 'absint', $show_on['specific'] ) ) )
			: array();

		if ( ! empty( $specific ) ) {
			foreach ( $specific as $post_id ) {
				$post_type    = get_post_type( $post_id );
				$conditions[] = array(
					'condition' => 'include',
					'category'  => 'singular',
					'type'      => is_string( $post_type ) && '' !== $post_type ? $post_type : 'post',
					'value'     => (int) $post_id,
				);
			}
			return $conditions;
		}

		$rules = isset( $show_on['rules'] ) && is_array( $show_on['rules'] ) ? $show_on['rules'] : array();
		foreach ( $rules as $rule ) {
			$rule = (string) $rule;
			if ( 'basic-global' === $rule ) {
				$conditions[] = array(
					'condition' => 'include',
					'category'  => 'general',
					'type'      => 'all',
					'value'     => 0,
				);
				continue;
			}
			if ( 'special-front' === $rule ) {
				$conditions[] = array(
					'condition' => 'include',
					'category'  => 'general',
					'type'      => 'front',
					'value'     => 0,
				);
				continue;
			}
			if ( 0 === strpos( $rule, 'post|' ) ) {
				$post_type    = substr( $rule, 5 );
				$conditions[] = array(
					'condition' => 'include',
					'category'  => 'singular',
					'type'      => ( 'all' === $post_type ) ? 'all' : $post_type,
					'value'     => 0,
				);
				continue;
			}
			if ( 0 === strpos( $rule, 'tax|' ) ) {
				$taxonomy     = substr( $rule, 4 );
				$conditions[] = array(
					'condition' => 'include',
					'category'  => 'archive',
					'type'      => $taxonomy,
					'value'     => 0,
				);
			}
		}

		return $conditions;
	}

	/**
	 * Find an existing migrated template post ID by source option key.
	 *
	 * @param string $source_id Source schemas key.
	 * @return int
	 */
	public static function find_migrated_template_id( $source_id ) {
		$source_id = (string) $source_id;
		if ( '' === $source_id ) {
			return 0;
		}

		$posts = get_posts(
			array(
				'post_type'              => SchemaTemplate::POST_TYPE,
				'post_status'            => array( 'publish', 'draft', 'pending', 'private' ),
				'posts_per_page'         => 1,
				'fields'                 => 'ids',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Stable migration lookup by design.
				'meta_key'               => self::META_MIGRATION_SOURCE_ID,
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Exact source id match.
				'meta_value'             => $source_id,
			)
		);

		if ( ! empty( $posts[0] ) ) {
			return (int) $posts[0];
		}

		// Recover orphans from a prior fatal during wp_insert_post (meta never written).
		$migration_id = self::stable_migration_id( $source_id );
		$by_name      = get_posts(
			array(
				'post_type'              => SchemaTemplate::POST_TYPE,
				'name'                   => $migration_id,
				'post_status'            => array( 'publish', 'draft', 'pending', 'private' ),
				'posts_per_page'         => 1,
				'fields'                 => 'ids',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
			)
		);

		return ! empty( $by_name[0] ) ? (int) $by_name[0] : 0;
	}

	/**
	 * Create or update a migrated schema template for a global entry.
	 *
	 * @param string $source_id      Original schemas option key.
	 * @param array  $schema         Schema entry.
	 * @param string $status         publish|draft.
	 * @param string $classification Classification constant.
	 * @return int Template post ID (0 on failure).
	 */
	public static function upsert_migrated_template( $source_id, array $schema, $status, $classification ) {
		$source_id    = (string) $source_id;
		$status       = ( 'publish' === $status ) ? 'publish' : 'draft';
		$type         = (string) ( $schema['type'] ?? 'Thing' );
		$migration_id = self::stable_migration_id( $source_id );
		$title        = sprintf(
			/* translators: 1: schema type, 2: short migration id */
			__( 'Migrated %1$s (%2$s)', 'seovault-ai' ),
			$type,
			substr( $migration_id, -8 )
		);

		$payload = array(
			'title'    => $title,
			'type'     => $type,
			'enabled'  => true,
			'fields'   => self::extract_field_values( $schema['fields'] ?? array() ),
			'metadata' => array(
				'title'             => $title,
				'displayConditions' => self::show_on_to_display_conditions(
					isset( $schema['show_on'] ) && is_array( $schema['show_on'] ) ? $schema['show_on'] : array()
				),
				'migration'         => array(
					'source_id'      => $source_id,
					'migration_id'   => $migration_id,
					'classification' => (string) $classification,
					'from'           => 'global_schemas',
				),
				'source_id'         => $migration_id,
			),
		);

		$existing_id = self::find_migrated_template_id( $source_id );
		if ( $existing_id > 0 ) {
			wp_update_post(
				array(
					'ID'          => $existing_id,
					'post_title'  => $title,
					'post_status' => $status,
					'post_name'   => $migration_id,
				)
			);
			$document = SchemaTemplate::build_document_from_raw( $payload, $existing_id, $title );
			$document['metadata']['migration'] = $payload['metadata']['migration'];
			$document['metadata']['source_id'] = $migration_id;
			SchemaTemplate::write_document( $existing_id, $document );
			update_post_meta( $existing_id, self::META_MIGRATION_SOURCE_ID, $source_id );
			update_post_meta( $existing_id, self::META_MIGRATION_ID, $migration_id );
			return $existing_id;
		}

		// Explicit guid avoids wp_insert_post → get_permalink (unsafe before rewrite is ready).
		$post_id = wp_insert_post(
			array(
				'post_type'   => SchemaTemplate::POST_TYPE,
				'post_title'  => $title,
				'post_status' => $status,
				'post_name'   => $migration_id,
				'guid'        => home_url( '/?post_type=' . SchemaTemplate::POST_TYPE . '&name=' . rawurlencode( $migration_id ) ),
			),
			true
		);

		if ( is_wp_error( $post_id ) || ! $post_id ) {
			return 0;
		}

		$post_id = (int) $post_id;
		update_post_meta( $post_id, self::META_MIGRATION_SOURCE_ID, $source_id );
		update_post_meta( $post_id, self::META_MIGRATION_ID, $migration_id );
		$document = SchemaTemplate::build_document_from_raw( $payload, $post_id, $title );
		$document['metadata']['migration'] = $payload['metadata']['migration'];
		$document['metadata']['source_id'] = $migration_id;
		SchemaTemplate::write_document( $post_id, $document );

		return $post_id;
	}

	/**
	 * Product without specific post IDs is owned by WooCommerce after V2.
	 *
	 * @param array $schema Schema entry.
	 * @return bool
	 */
	public static function should_defer_product_to_integration( array $schema ) {
		if ( 'Product' !== (string) ( $schema['type'] ?? '' ) ) {
			return false;
		}
		$specific = isset( $schema['show_on']['specific'] ) && is_array( $schema['show_on']['specific'] )
			? array_filter( array_map( 'absint', $schema['show_on']['specific'] ) )
			: array();
		return empty( $specific );
	}

	/**
	 * Run dry-run analysis: lock, optional backup, classify, write sanitized report.
	 *
	 * Does not modify `seovaultseo_schemas` or bump the migration completion version.
	 *
	 * @param array $args {
	 *     Optional arguments.
	 *
	 *     @type bool $write_backup Whether to write/refresh the checksummed backup. Default true.
	 * }
	 * @return array{
	 *   success:bool,
	 *   error:?string,
	 *   report:?array,
	 *   lock_token:?string
	 * }
	 */
	public static function dry_run( array $args = array() ) {
		$write_backup = ! isset( $args['write_backup'] ) || (bool) $args['write_backup'];

		$token = self::acquire_lock();
		if ( ! $token ) {
			return array(
				'success'    => false,
				'error'      => 'lock_contention',
				'report'     => null,
				'lock_token' => null,
			);
		}

		try {
			$schemas_raw = get_option( 'seovaultseo_schemas', false );
			$schemas     = is_array( $schemas_raw ) ? $schemas_raw : array();
			$present     = false !== $schemas_raw;

			$backup_meta = array(
				'written'  => false,
				'checksum' => '',
				'verified' => false,
			);

			if ( $write_backup ) {
				$backup = self::write_backup( $schemas, $present );
				if ( is_wp_error( $backup ) ) {
					return array(
						'success'    => false,
						'error'      => $backup->get_error_code(),
						'report'     => null,
						'lock_token' => $token,
					);
				}
				$backup_meta = array(
					'written'  => true,
					'checksum' => (string) ( $backup['checksum'] ?? '' ),
					'verified' => self::verify_backup( $backup ),
				);
			}

			$entries = self::classify_schemas( $schemas );
			$report  = self::build_report( $entries, $present, $backup_meta );

			update_option( 'seovaultseo_' . self::OPTION_REPORT, $report, false );

			return array(
				'success'    => true,
				'error'      => null,
				'report'     => $report,
				'lock_token' => $token,
			);
		} finally {
			self::release_lock( $token );
		}
	}

	/**
	 * Classify every entry in a schemas option map.
	 *
	 * @param array $schemas Schemas option map.
	 * @return array<int,array<string,mixed>>
	 */
	public static function classify_schemas( array $schemas ) {
		$factory_by_type = self::factory_by_type();
		$classified      = array();

		foreach ( $schemas as $id => $schema ) {
			$classified[] = self::classify_entry( (string) $id, $schema, $factory_by_type );
		}

		return $classified;
	}

	/**
	 * Classify one global schema entry.
	 *
	 * @param string $id              Entry ID.
	 * @param mixed  $schema          Schema payload.
	 * @param array  $factory_by_type Factory schemas keyed by type.
	 * @return array<string,mixed>
	 */
	public static function classify_entry( $id, $schema, array $factory_by_type = array() ) {
		$id = (string) $id;

		if ( ! is_array( $schema ) ) {
			return self::entry_result(
				$id,
				'',
				false,
				'unknown',
				self::CLASS_UNKNOWN_EXTENSION,
				'preserve_and_review'
			);
		}

		$type    = (string) ( $schema['type'] ?? '' );
		$enabled = ! empty( $schema['enabled'] );

		if ( '' === $type || null === SchemaTypePolicyRegistry::get_entry( $type ) ) {
			return self::entry_result(
				$id,
				$type,
				$enabled,
				self::targeting_label( $schema ),
				self::CLASS_UNKNOWN_EXTENSION,
				'preserve_and_review'
			);
		}

		if ( SchemaTypePolicyRegistry::is_foundational( $type ) ) {
			return self::entry_result(
				$id,
				$type,
				$enabled,
				self::targeting_label( $schema ),
				self::CLASS_SAFE_AUTOMATIC,
				'keep'
			);
		}

		if ( empty( $factory_by_type ) ) {
			$factory_by_type = self::factory_by_type();
		}

		$factory   = isset( $factory_by_type[ $type ] ) && is_array( $factory_by_type[ $type ] ) ? $factory_by_type[ $type ] : array();
		$is_broad  = self::is_broad_targeting( $schema );
		$targeting = $is_broad ? 'broad' : 'narrow';
		$empty     = self::is_empty_or_factory_identical( $schema, $factory );

		if ( $is_broad ) {
			if ( $empty ) {
				return self::entry_result(
					$id,
					$type,
					$enabled,
					$targeting,
					self::CLASS_EMPTY_SPECIALIZED_BROAD,
					'retire'
				);
			}

			return self::entry_result(
				$id,
				$type,
				$enabled,
				$targeting,
				self::CLASS_MEANINGFUL_SPECIALIZED_BROAD,
				'convert_to_inactive_draft'
			);
		}

		if ( self::is_complete_ready( $type, $schema ) ) {
			return self::entry_result(
				$id,
				$type,
				$enabled,
				$targeting,
				self::CLASS_COMPLETE_SPECIALIZED_NARROW,
				'convert_to_v2_template'
			);
		}

		return self::entry_result(
			$id,
			$type,
			$enabled,
			$targeting,
			self::CLASS_INCOMPLETE_SPECIALIZED_NARROW,
			'convert_to_template_draft'
		);
	}

	/**
	 * Whether display rules are broad / site-wide spray.
	 *
	 * Broad: empty rules (match-all), basic-global, post|all, post|post, post|page,
	 * or only those rules without specific post IDs.
	 *
	 * @param array $schema Schema entry.
	 * @return bool
	 */
	public static function is_broad_targeting( array $schema ) {
		$specific = isset( $schema['show_on']['specific'] ) && is_array( $schema['show_on']['specific'] )
			? array_filter( array_map( 'absint', $schema['show_on']['specific'] ) )
			: array();

		if ( ! empty( $specific ) ) {
			return false;
		}

		$rules = isset( $schema['show_on']['rules'] ) && is_array( $schema['show_on']['rules'] )
			? $schema['show_on']['rules']
			: array();

		if ( empty( $rules ) ) {
			return true;
		}

		foreach ( $rules as $rule ) {
			$rule = (string) $rule;
			if ( in_array( $rule, self::$broad_rules, true ) ) {
				continue;
			}
			// Any non-broad rule (e.g. post|product, tax|*, special-front) is narrow.
			return false;
		}

		return true;
	}

	/**
	 * Whether fields/show_on/not_show_on match factory (or have no meaningful customization).
	 *
	 * Passing a sanitizer is not treated as intentional configuration.
	 *
	 * @param array $schema  Saved schema.
	 * @param array $factory Factory schema for the same type.
	 * @return bool
	 */
	public static function is_empty_or_factory_identical( array $schema, array $factory ) {
		if ( empty( $factory ) ) {
			// No factory baseline: treat as empty only when fields lack meaningful values.
			return ! self::fields_have_meaningful_values( self::extract_field_values( $schema['fields'] ?? array() ) );
		}

		foreach ( array( 'fields', 'show_on', 'not_show_on' ) as $key ) {
			$saved_value   = $schema[ $key ] ?? array();
			$factory_value = $factory[ $key ] ?? array();
			if ( self::canonicalize( $saved_value ) !== self::canonicalize( $factory_value ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Recursively sort associative keys for stable comparison / checksums.
	 *
	 * @param mixed $value Value.
	 * @return mixed
	 */
	public static function canonicalize( $value ) {
		if ( ! is_array( $value ) ) {
			return $value;
		}

		if ( array() === $value ) {
			return $value;
		}

		if ( array_keys( $value ) !== range( 0, count( $value ) - 1 ) ) {
			ksort( $value, SORT_STRING );
		}

		foreach ( $value as $key => $item ) {
			$value[ $key ] = self::canonicalize( $item );
		}

		return $value;
	}

	/**
	 * Compute a stable checksum for a schemas option payload.
	 *
	 * @param array $schemas Schemas map.
	 * @return string
	 */
	public static function checksum_schemas( array $schemas ) {
		$encoded = wp_json_encode( self::canonicalize( $schemas ) );
		if ( ! is_string( $encoded ) ) {
			$encoded = '';
		}
		return hash( 'sha256', $encoded );
	}

	/**
	 * Write a versioned backup with autoload disabled.
	 *
	 * @param array $schemas Schemas map (may be empty).
	 * @param bool  $present Whether the option row existed.
	 * @return array|\WP_Error Backup payload or error.
	 */
	public static function write_backup( array $schemas, $present = true ) {
		$backup = array(
			'created_at'               => gmdate( 'c' ),
			'plugin_version'           => defined( 'SEOVAULT_SEO_VERSION' ) ? SEOVAULT_SEO_VERSION : '',
			'architecture_version'     => SchemaArchitectureSettings::get_architecture_version(),
			'target_migration_version' => self::TARGET_MIGRATION_VERSION,
			'legacy_defaults_marker'   => (string) get_option( SchemaDefaultsMigration::MARKER_OPTION, '' ),
			'schemas_option_present'   => (bool) $present,
			'checksum'                 => self::checksum_schemas( $schemas ),
			'schemas'                  => $schemas,
			'scope'                    => 'global_schemas_option',
			'notes'                    => 'Global schemas snapshot only. Per-post schema meta and custom JSON are left untouched.',
		);

		$option = 'seovaultseo_' . self::OPTION_BACKUP;
		if ( false === get_option( $option, false ) ) {
			$ok = add_option( $option, $backup, '', false );
		} else {
			$ok = update_option( $option, $backup, false );
		}

		if ( ! $ok && get_option( $option, null ) !== $backup ) {
			return new \WP_Error( 'backup_write_failed', 'Failed to persist schema V2 migration backup.' );
		}

		return $backup;
	}

	/**
	 * Load the stored backup payload.
	 *
	 * @return array|null
	 */
	public static function get_backup() {
		$backup = get_option( 'seovaultseo_' . self::OPTION_BACKUP, null );
		return is_array( $backup ) ? $backup : null;
	}

	/**
	 * Verify a backup payload checksum (and basic shape).
	 *
	 * @param array|null $backup Backup payload.
	 * @return bool
	 */
	public static function verify_backup( $backup = null ) {
		if ( null === $backup ) {
			$backup = self::get_backup();
		}
		if ( ! is_array( $backup ) ) {
			return false;
		}
		if ( ! isset( $backup['checksum'] ) || ! is_string( $backup['checksum'] ) || '' === $backup['checksum'] ) {
			return false;
		}
		if ( ! isset( $backup['schemas'] ) || ! is_array( $backup['schemas'] ) ) {
			return false;
		}

		return hash_equals( $backup['checksum'], self::checksum_schemas( $backup['schemas'] ) );
	}

	/**
	 * Acquire a short migration lock.
	 *
	 * @return string|false Lock token or false on contention.
	 */
	public static function acquire_lock() {
		$option = 'seovaultseo_' . self::OPTION_LOCK;
		$now    = time();
		$existing = get_option( $option, null );

		if ( is_array( $existing ) ) {
			$expires = isset( $existing['expires_at'] ) ? (int) $existing['expires_at'] : 0;
			if ( $expires > $now ) {
				return false;
			}
		}

		$token = wp_generate_uuid4();
		$lock  = array(
			'token'       => $token,
			'acquired_at' => $now,
			'expires_at'  => $now + self::LOCK_TTL,
		);

		if ( false === get_option( $option, false ) ) {
			add_option( $option, $lock, '', false );
		} else {
			update_option( $option, $lock, false );
		}

		$stored = get_option( $option, null );
		if ( ! is_array( $stored ) || (string) ( $stored['token'] ?? '' ) !== $token ) {
			return false;
		}

		return $token;
	}

	/**
	 * Release a previously acquired lock.
	 *
	 * @param string $token Lock token.
	 * @return bool
	 */
	public static function release_lock( $token ) {
		$token  = (string) $token;
		$option = 'seovaultseo_' . self::OPTION_LOCK;
		$stored = get_option( $option, null );

		if ( ! is_array( $stored ) || (string) ( $stored['token'] ?? '' ) !== $token ) {
			return false;
		}

		return delete_option( $option );
	}

	/**
	 * Whether a lock is currently held (unexpired).
	 *
	 * @return bool
	 */
	public static function is_locked() {
		$stored = get_option( 'seovaultseo_' . self::OPTION_LOCK, null );
		if ( ! is_array( $stored ) ) {
			return false;
		}
		return isset( $stored['expires_at'] ) && (int) $stored['expires_at'] > time();
	}

	/**
	 * Last sanitized report.
	 *
	 * @return array|null
	 */
	public static function get_report() {
		$report = get_option( 'seovaultseo_' . self::OPTION_REPORT, null );
		return is_array( $report ) ? $report : null;
	}

	/**
	 * Notice / restore-window state.
	 *
	 * @return array{status:string,updated_at?:string,dismissed_at?:string,restored_at?:string}
	 */
	public static function get_notice_state() {
		$state = get_option( 'seovaultseo_' . self::OPTION_NOTICE, null );
		if ( ! is_array( $state ) ) {
			return array(
				'status' => 'none',
			);
		}

		$status = (string) ( $state['status'] ?? 'none' );
		if ( ! in_array( $status, array( self::NOTICE_PENDING, self::NOTICE_DISMISSED, self::NOTICE_RESTORED, 'none' ), true ) ) {
			$status = 'none';
		}

		$out = array(
			'status' => $status,
		);
		foreach ( array( 'updated_at', 'dismissed_at', 'restored_at' ) as $key ) {
			if ( ! empty( $state[ $key ] ) && is_string( $state[ $key ] ) ) {
				$out[ $key ] = $state[ $key ];
			}
		}

		return $out;
	}

	/**
	 * Whether the one-time migration notice should render for capable admins.
	 *
	 * @return bool
	 */
	public static function should_show_notice() {
		$state = self::get_notice_state();
		if ( self::NOTICE_PENDING !== (string) ( $state['status'] ?? '' ) ) {
			return false;
		}

		// Notice is only meaningful after a production transform (or a verified backup window).
		if ( self::get_migration_version() >= self::VERSION_AFTER_GLOBAL ) {
			return true;
		}

		$report = self::get_report();
		return is_array( $report ) && ! empty( $report['production_transformed'] );
	}

	/**
	 * Mark the one-time notice as pending (does not delete the backup).
	 *
	 * @param bool $force Re-open even after dismiss/restore (e.g. after a new transform).
	 * @return void
	 */
	public static function mark_notice_pending( $force = false ) {
		$existing = self::get_notice_state();
		$status   = (string) ( $existing['status'] ?? 'none' );

		if ( ! $force && self::NOTICE_PENDING === $status ) {
			return;
		}

		$payload = array(
			'status'     => self::NOTICE_PENDING,
			'updated_at' => gmdate( 'c' ),
		);

		$option = 'seovaultseo_' . self::OPTION_NOTICE;
		if ( false === get_option( $option, false ) ) {
			add_option( $option, $payload, '', false );
		} else {
			update_option( $option, $payload, false );
		}
	}

	/**
	 * Dismiss the notice without deleting the checksummed backup.
	 *
	 * @return void
	 */
	public static function dismiss_notice() {
		$payload = array(
			'status'       => self::NOTICE_DISMISSED,
			'updated_at'   => gmdate( 'c' ),
			'dismissed_at' => gmdate( 'c' ),
		);

		$option = 'seovaultseo_' . self::OPTION_NOTICE;
		if ( false === get_option( $option, false ) ) {
			add_option( $option, $payload, '', false );
		} else {
			update_option( $option, $payload, false );
		}
	}

	/**
	 * Record that legacy configuration was restored.
	 *
	 * @return void
	 */
	public static function mark_notice_restored() {
		$payload = array(
			'status'      => self::NOTICE_RESTORED,
			'updated_at'  => gmdate( 'c' ),
			'restored_at' => gmdate( 'c' ),
		);

		$option = 'seovaultseo_' . self::OPTION_NOTICE;
		if ( false === get_option( $option, false ) ) {
			add_option( $option, $payload, '', false );
		} else {
			update_option( $option, $payload, false );
		}
	}

	/**
	 * Whether a verified backup is available for restore (rollback window).
	 *
	 * @return bool
	 */
	public static function is_restore_available() {
		return self::verify_backup();
	}

	/**
	 * Sanitized review summary for the admin report screen (no raw bodies / secrets).
	 *
	 * @return array<string,mixed>
	 */
	public static function get_review_summary() {
		$report   = self::get_report();
		$progress = class_exists( SchemaPostMigration::class )
			? SchemaPostMigration::get_progress()
			: array();
		$backup   = self::get_backup();
		$entries  = array();

		if ( is_array( $report ) && ! empty( $report['entries'] ) && is_array( $report['entries'] ) ) {
			foreach ( $report['entries'] as $entry ) {
				if ( ! is_array( $entry ) ) {
					continue;
				}
				$class = (string) ( $entry['classification'] ?? '' );
				if ( self::CLASS_SAFE_AUTOMATIC === $class ) {
					continue;
				}
				$entries[] = array(
					'id'              => (string) ( $entry['id'] ?? '' ),
					'type'            => (string) ( $entry['type'] ?? '' ),
					'enabled'         => ! empty( $entry['enabled'] ),
					'targeting'       => (string) ( $entry['targeting'] ?? '' ),
					'classification'  => $class,
					'proposed_action' => (string) ( $entry['proposed_action'] ?? '' ),
					'label'           => self::classification_label( $class ),
				);
			}
		}

		return array(
			'migration_version'      => self::get_migration_version(),
			'notice'                 => self::get_notice_state(),
			'auto_migration_paused'  => self::is_auto_migration_paused(),
			'migration_needed'       => self::is_needed(),
			'backup'                 => array(
				'available'  => self::verify_backup( $backup ),
				'created_at' => is_array( $backup ) ? (string) ( $backup['created_at'] ?? '' ) : '',
				'checksum'   => is_array( $backup ) ? (string) ( $backup['checksum'] ?? '' ) : '',
				'scope'      => is_array( $backup ) ? (string) ( $backup['scope'] ?? 'global_schemas_option' ) : 'global_schemas_option',
			),
			'global'                 => array(
				'counts'      => is_array( $report ) && isset( $report['counts'] ) && is_array( $report['counts'] ) ? $report['counts'] : array(),
				'transformed' => is_array( $report ) && isset( $report['transformed'] ) && is_array( $report['transformed'] ) ? $report['transformed'] : array(),
				'entries'     => $entries,
			),
			'posts'                  => array(
				'status'          => (string) ( $progress['status'] ?? '' ),
				'processed'       => (int) ( $progress['processed'] ?? 0 ),
				'converted_ready' => (int) ( $progress['converted_ready'] ?? 0 ),
				'converted_draft' => (int) ( $progress['converted_draft'] ?? 0 ),
				'custom_valid'    => (int) ( $progress['custom_valid'] ?? 0 ),
				'custom_invalid'  => (int) ( $progress['custom_invalid'] ?? 0 ),
				'skipped'         => (int) ( $progress['skipped'] ?? 0 ),
			),
			'restore_consequences'    => self::get_restore_consequence_lines(),
		);
	}

	/**
	 * Human-readable classification label (safe for escaped admin output).
	 *
	 * @param string $classification Classification constant.
	 * @return string
	 */
	public static function classification_label( $classification ) {
		switch ( (string) $classification ) {
			case self::CLASS_EMPTY_SPECIALIZED_BROAD:
				return __( 'Retired empty specialized (broad targeting)', 'seovault-ai' );
			case self::CLASS_MEANINGFUL_SPECIALIZED_BROAD:
				return __( 'Converted to inactive draft template (broad targeting)', 'seovault-ai' );
			case self::CLASS_COMPLETE_SPECIALIZED_NARROW:
				return __( 'Converted to active V2 template (narrow targeting)', 'seovault-ai' );
			case self::CLASS_INCOMPLETE_SPECIALIZED_NARROW:
				return __( 'Converted to unfinished draft template', 'seovault-ai' );
			case self::CLASS_UNKNOWN_EXTENSION:
				return __( 'Preserved extension / unknown type for review', 'seovault-ai' );
			case self::CLASS_SAFE_AUTOMATIC:
				return __( 'Kept foundational automatic schema', 'seovault-ai' );
			default:
				return __( 'Reviewed schema entry', 'seovault-ai' );
		}
	}

	/**
	 * Explicit warnings about what restore re-enables.
	 *
	 * @return string[]
	 */
	public static function get_restore_consequence_lines() {
		return array(
			__( 'Your previous global schema configuration will be written back from the verified backup.', 'seovault-ai' ),
			__( 'Optional specialized types such as Recipe, Event, Course, HowTo, Review, JobPosting, and SoftwareApplication may again target ordinary posts when those entries were enabled with broad display rules.', 'seovault-ai' ),
			__( 'Migrated schema template posts created by this upgrade will be removed so they do not compete with the restored global configuration.', 'seovault-ai' ),
			__( 'The Schema Architecture V2 code path remains installed; restore does not uninstall V2. It reloads legacy configuration data and clears the V2 migration completion marker.', 'seovault-ai' ),
			__( 'Automatic remigration stays paused after restore so this change is not undone on the next page load. Use “Re-run schema migration” on this page when you are ready to migrate again.', 'seovault-ai' ),
			__( 'Per-post V2 instances and custom JSON mode metadata are not reversed by this global restore (the backup covers the global schemas option only).', 'seovault-ai' ),
		);
	}

	/**
	 * Delete template posts created by the V2 global migration.
	 *
	 * @return int Number of templates deleted.
	 */
	public static function delete_migrated_templates() {
		SchemaTemplate::register_post_type();

		$deleted = 0;
		$paged   = 1;

		do {
			$posts = get_posts(
				array(
					'post_type'              => SchemaTemplate::POST_TYPE,
					'post_status'            => array( 'publish', 'draft', 'pending', 'private', 'trash' ),
					'posts_per_page'         => 100,
					'paged'                  => $paged,
					'fields'                 => 'ids',
					'no_found_rows'          => true,
					'update_post_meta_cache' => false,
					'update_post_term_cache' => false,
					// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- Migration rollback lookup.
					'meta_key'               => self::META_MIGRATION_SOURCE_ID,
				)
			);

			if ( empty( $posts ) ) {
				break;
			}

			foreach ( $posts as $post_id ) {
				$post_id = (int) $post_id;
				if ( $post_id <= 0 ) {
					continue;
				}
				if ( wp_delete_post( $post_id, true ) ) {
					++$deleted;
				}
			}

			++$paged;
		} while ( count( $posts ) >= 100 );

		return $deleted;
	}

	/**
	 * Restore global schemas from the verified checksummed backup.
	 *
	 * Capability and nonce checks belong to the admin UI layer. This method
	 * still requires an explicit confirmed=true argument.
	 *
	 * @param array $args {
	 *     Optional arguments.
	 *
	 *     @type bool $confirmed Required explicit confirmation flag.
	 * }
	 * @return array{
	 *   success:bool,
	 *   error:?string,
	 *   templates_deleted:int,
	 *   checksum:?string,
	 *   repeated:bool
	 * }
	 */
	public static function restore_from_backup( array $args = array() ) {
		$result = array(
			'success'           => false,
			'error'             => null,
			'templates_deleted' => 0,
			'checksum'          => null,
			'repeated'          => false,
		);

		if ( empty( $args['confirmed'] ) ) {
			$result['error'] = 'confirmation_required';
			return $result;
		}

		$backup = self::get_backup();
		if ( null === $backup ) {
			$result['error'] = 'backup_missing';
			return $result;
		}

		if ( ! self::verify_backup( $backup ) ) {
			$result['error'] = 'backup_corrupt';
			return $result;
		}

		$checksum = (string) ( $backup['checksum'] ?? '' );
		$schemas  = isset( $backup['schemas'] ) && is_array( $backup['schemas'] ) ? $backup['schemas'] : array();
		$present  = ! empty( $backup['schemas_option_present'] );

		$current = get_option( 'seovaultseo_schemas', false );
		$current_schemas = is_array( $current ) ? $current : array();
		$already_matching = ( 0 === self::get_migration_version() )
			&& self::checksum_schemas( $current_schemas ) === $checksum
			&& ( ( false !== $current ) === $present || ( $present && false !== $current ) );

		$token = self::acquire_lock();
		if ( ! $token ) {
			$result['error'] = 'lock_contention';
			return $result;
		}

		try {
			\SEOvaultSEO\Schema\Validation\SchemaValidationInvalidator::begin_quiet();
			try {
				if ( $present || ! empty( $schemas ) ) {
					update_option( 'seovaultseo_schemas', $schemas, false );
				} else {
					delete_option( 'seovaultseo_schemas' );
				}

				$deleted = self::delete_migrated_templates();

				update_option( 'seovaultseo_' . self::OPTION_MIGRATION_VERSION, 0, false );

				$report = self::get_report();
				if ( is_array( $report ) ) {
					$report['restored']                  = true;
					$report['restored_at']               = gmdate( 'c' );
					$report['current_migration_version'] = 0;
					$report['migration_needed']          = true;
					update_option( 'seovaultseo_' . self::OPTION_REPORT, $report, false );
				}

				self::mark_notice_restored();
				// Keep restored legacy config until an explicit remigrate; do not let Upgrade undo this.
				self::pause_auto_migration();

				// Single generation bump after successful restore.
				SchemaValidationCache::bump_generation();
			} finally {
				\SEOvaultSEO\Schema\Validation\SchemaValidationInvalidator::end_quiet();
			}

			$result['success']           = true;
			$result['templates_deleted'] = $deleted;
			$result['checksum']          = $checksum;
			$result['repeated']          = $already_matching;
			return $result;
		} finally {
			self::release_lock( $token );
		}
	}

	/**
	 * Clear the post-restore pause and run migration (explicit admin / CLI path).
	 *
	 * @param array $args Optional arguments forwarded to {@see run()}.
	 * @return array Result from {@see run()}.
	 */
	public static function remigrate( array $args = array() ) {
		self::clear_auto_migration_pause();
		return self::run( $args );
	}

	/**
	 * Extract flat field values from stored schema fields (list or map).
	 *
	 * @param mixed $fields Fields payload.
	 * @return array
	 */
	public static function extract_field_values( $fields ) {
		if ( ! is_array( $fields ) ) {
			return array();
		}

		if ( self::is_list_of_field_defs( $fields ) ) {
			$parsed = array();
			foreach ( $fields as $field ) {
				if ( ! is_array( $field ) || empty( $field['id'] ) ) {
					continue;
				}
				$parsed[ (string) $field['id'] ] = $field['std'] ?? ( $field['value'] ?? '' );
			}
			return $parsed;
		}

		return $fields;
	}

	/**
	 * Build factory schemas keyed by type.
	 *
	 * @return array<string,array>
	 */
	public static function factory_by_type() {
		$result = array();
		foreach ( SchemaUtils::get_default_schemas() as $schema ) {
			if ( is_array( $schema ) && ! empty( $schema['type'] ) ) {
				$result[ (string) $schema['type'] ] = $schema;
			}
		}
		return $result;
	}

	/**
	 * Clear migration options (tests).
	 *
	 * @return void
	 */
	public static function reset_state() {
		delete_option( 'seovaultseo_' . self::OPTION_MIGRATION_VERSION );
		delete_option( 'seovaultseo_' . self::OPTION_BACKUP );
		delete_option( 'seovaultseo_' . self::OPTION_LOCK );
		delete_option( 'seovaultseo_' . self::OPTION_REPORT );
		delete_option( 'seovaultseo_' . self::OPTION_NOTICE );
		delete_option( 'seovaultseo_' . self::OPTION_AUTO_MIGRATION_PAUSED );
		if ( class_exists( SchemaPostMigration::class ) ) {
			SchemaPostMigration::reset_state();
		}
	}

	/**
	 * Whether specialized field payload is readiness-complete.
	 *
	 * @param string $type   Schema type.
	 * @param array  $schema Schema entry.
	 * @return bool
	 */
	private static function is_complete_ready( $type, array $schema ) {
		$fields = self::extract_field_values( $schema['fields'] ?? array() );
		$result = SchemaReadinessEvaluator::evaluate( $type, $fields );
		return $result->is_ready();
	}

	/**
	 * Whether extracted fields contain any meaningful non-placeholder values.
	 *
	 * @param array $fields Flat fields.
	 * @return bool
	 */
	private static function fields_have_meaningful_values( array $fields ) {
		foreach ( $fields as $key => $value ) {
			$key = (string) $key;
			// Identity scaffolding alone does not count as intentional specialized config.
			if ( '@id' === $key || 'schema_name' === $key || '@type' === $key ) {
				continue;
			}
			if ( \SEOvaultSEO\Schema\Readiness\SchemaReadinessValues::is_meaningful( $value ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Detect list-of-field-definition shape.
	 *
	 * @param array $fields Fields.
	 * @return bool
	 */
	private static function is_list_of_field_defs( array $fields ) {
		if ( array() === $fields ) {
			return false;
		}
		if ( array_keys( $fields ) !== range( 0, count( $fields ) - 1 ) ) {
			return false;
		}
		$first = $fields[0];
		return is_array( $first ) && isset( $first['id'] );
	}

	/**
	 * Targeting label for reports.
	 *
	 * @param array $schema Schema.
	 * @return string
	 */
	private static function targeting_label( array $schema ) {
		return self::is_broad_targeting( $schema ) ? 'broad' : 'narrow';
	}

	/**
	 * Build one classified entry row (no raw field content).
	 *
	 * @param string $id               Entry ID.
	 * @param string $type             Type.
	 * @param bool   $enabled          Enabled flag.
	 * @param string $targeting        broad|narrow|unknown.
	 * @param string $classification   Classification constant.
	 * @param string $proposed_action  Proposed Phase 10 action.
	 * @return array<string,mixed>
	 */
	private static function entry_result( $id, $type, $enabled, $targeting, $classification, $proposed_action ) {
		return array(
			'id'              => (string) $id,
			'type'            => (string) $type,
			'enabled'         => (bool) $enabled,
			'targeting'       => (string) $targeting,
			'classification'  => (string) $classification,
			'proposed_action' => (string) $proposed_action,
		);
	}

	/**
	 * Build a sanitized dry-run report (no secrets / raw schema bodies).
	 *
	 * @param array $entries     Classified entries.
	 * @param bool  $present     Whether schemas option existed.
	 * @param array $backup_meta Backup metadata for the report.
	 * @return array<string,mixed>
	 */
	private static function build_report( array $entries, $present, array $backup_meta ) {
		$counts = array(
			self::CLASS_SAFE_AUTOMATIC                 => 0,
			self::CLASS_EMPTY_SPECIALIZED_BROAD        => 0,
			self::CLASS_MEANINGFUL_SPECIALIZED_BROAD   => 0,
			self::CLASS_COMPLETE_SPECIALIZED_NARROW    => 0,
			self::CLASS_INCOMPLETE_SPECIALIZED_NARROW  => 0,
			self::CLASS_UNKNOWN_EXTENSION              => 0,
		);

		$safe_entries = array();
		foreach ( $entries as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}
			$class = (string) ( $entry['classification'] ?? '' );
			if ( isset( $counts[ $class ] ) ) {
				++$counts[ $class ];
			}
			$safe_entries[] = array(
				'id'              => (string) ( $entry['id'] ?? '' ),
				'type'            => (string) ( $entry['type'] ?? '' ),
				'enabled'         => ! empty( $entry['enabled'] ),
				'targeting'       => (string) ( $entry['targeting'] ?? '' ),
				'classification'  => $class,
				'proposed_action' => (string) ( $entry['proposed_action'] ?? '' ),
			);
		}

		return array(
			'analyzed_at'               => gmdate( 'c' ),
			'plugin_version'            => defined( 'SEOVAULT_SEO_VERSION' ) ? SEOVAULT_SEO_VERSION : '',
			'target_migration_version'  => self::TARGET_MIGRATION_VERSION,
			'current_migration_version' => self::get_migration_version(),
			'migration_needed'          => self::is_needed(),
			'legacy_defaults_marker'    => (string) get_option( SchemaDefaultsMigration::MARKER_OPTION, '' ),
			'schemas_option_present'    => (bool) $present,
			'entry_count'               => count( $safe_entries ),
			'counts'                    => $counts,
			'entries'                   => $safe_entries,
			'backup'                    => array(
				'written'  => ! empty( $backup_meta['written'] ),
				'checksum' => (string) ( $backup_meta['checksum'] ?? '' ),
				'verified' => ! empty( $backup_meta['verified'] ),
			),
			'production_transformed'    => false,
		);
	}
}
