<?php
/**
 * Schema Architecture V2 per-post batch migration.
 *
 * Converts legacy `_seovault_schemas` into `_seovault_schema_instances_v2`,
 * records legacy replace-mode metadata for valid custom JSON, and backs up
 * invalid custom JSON so it cannot suppress foundational output.
 *
 * Does not scan posts on ordinary frontend requests — callers must opt in
 * (admin / cron / CLI / tests).
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

use SEOvaultSEO\Core\PostMeta;
use SEOvaultSEO\Schema\Readiness\SchemaReadinessEvaluator;
use SEOvaultSEO\Schema\Validation\SchemaValidationCache;
use SEOvaultSEO\Schema\Validation\SchemaValidationInvalidator;

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

/**
 * Resumable per-post schema / custom JSON migration.
 */
class SchemaPostMigration {

	/**
	 * Posts processed per batch.
	 */
	const BATCH_SIZE = 10;

	/**
	 * Progress option key (without seovaultseo_ prefix via full option name).
	 */
	const OPTION_PROGRESS = 'schema_v2_post_migration_progress';

	/**
	 * V2 instances post meta (same key as SchemaInstanceStorage::META_KEY).
	 */
	const META_INSTANCES = '_seovault_schema_instances_v2';

	/**
	 * Custom JSON mode: replace | add | invalid_withheld.
	 */
	const META_JSON_MODE = '_seovault_schema_json_mode';

	/**
	 * Backup of invalid custom JSON (raw string).
	 */
	const META_JSON_INVALID_BACKUP = '_seovault_schema_json_invalid_backup';

	/**
	 * Marker that this post's legacy schema meta was migrated.
	 */
	const META_POST_MIGRATED = '_seovault_schema_post_migrated_v2';

	/**
	 * Whether post batches may run in this request context.
	 *
	 * @return bool
	 */
	public static function can_run_in_current_context() {
		/**
		 * Force allow/deny post-batch execution. Return null to use defaults.
		 *
		 * @param null|bool $allowed Null for default context rules.
		 */
		$forced = apply_filters( 'seovaultseo_schema_v2_allow_post_migration', null );
		if ( null !== $forced ) {
			return (bool) $forced;
		}

		if ( defined( 'WP_CLI' ) && \WP_CLI ) {
			return true;
		}
		if ( wp_doing_cron() ) {
			return true;
		}
		if ( is_admin() ) {
			return true;
		}
		return false;
	}

	/**
	 * Load progress payload.
	 *
	 * @return array<string,mixed>
	 */
	public static function get_progress() {
		$progress = get_option( 'seovaultseo_' . self::OPTION_PROGRESS, null );
		if ( ! is_array( $progress ) ) {
			return self::default_progress();
		}
		return array_merge( self::default_progress(), $progress );
	}

	/**
	 * Whether per-post migration has fully completed.
	 *
	 * @return bool
	 */
	public static function is_complete() {
		$progress = self::get_progress();
		return 'complete' === (string) ( $progress['status'] ?? '' );
	}

	/**
	 * Process batches until complete or max_batches exhausted.
	 *
	 * @param int $max_batches Max batches this call (0 = until complete).
	 * @return array<string,mixed>
	 */
	public static function run( $max_batches = 0 ) {
		$max_batches = max( 0, (int) $max_batches );
		$batches     = 0;
		$last        = array(
			'success'            => true,
			'complete'           => self::is_complete(),
			'processed_in_batch' => 0,
			'progress'           => self::get_progress(),
		);

		while ( ! self::is_complete() && ( 0 === $max_batches || $batches < $max_batches ) ) {
			$last = self::process_next_batch();
			++$batches;
			if ( ! empty( $last['complete'] ) ) {
				break;
			}
			if ( empty( $last['processed_in_batch'] ) ) {
				break;
			}
		}

		return $last;
	}

	/**
	 * Process a single batch of posts.
	 *
	 * @return array<string,mixed>
	 */
	public static function process_next_batch() {
		if ( self::is_complete() ) {
			return array(
				'success'            => true,
				'complete'           => true,
				'processed_in_batch' => 0,
				'progress'           => self::get_progress(),
			);
		}

		$progress = self::get_progress();
		$progress['status']     = 'running';
		$progress['batch_size'] = self::BATCH_SIZE;
		self::save_progress( $progress );

		$post_ids = self::query_batch_post_ids( (int) ( $progress['last_post_id'] ?? 0 ) );
		if ( empty( $post_ids ) ) {
			return self::finalize_if_idle();
		}

		SchemaValidationInvalidator::begin_quiet();
		$batch_counts = array(
			'processed_in_batch' => 0,
			'converted_ready'    => 0,
			'converted_draft'    => 0,
			'custom_valid'       => 0,
			'custom_invalid'     => 0,
			'skipped'            => 0,
		);

		try {
			foreach ( $post_ids as $post_id ) {
				$post_id = (int) $post_id;
				$result  = self::migrate_post( $post_id );
				++$batch_counts['processed_in_batch'];
				$progress['processed'] = (int) ( $progress['processed'] ?? 0 ) + 1;
				$progress['last_post_id'] = $post_id;

				foreach ( array( 'converted_ready', 'converted_draft', 'custom_valid', 'custom_invalid', 'skipped' ) as $key ) {
					if ( ! empty( $result[ $key ] ) ) {
						$batch_counts[ $key ] += (int) $result[ $key ];
						$progress[ $key ]      = (int) ( $progress[ $key ] ?? 0 ) + (int) $result[ $key ];
					}
				}
			}
		} finally {
			SchemaValidationInvalidator::end_quiet();
		}

		self::save_progress( $progress );

		// More posts may remain.
		$more = self::query_batch_post_ids( (int) $progress['last_post_id'] );
		if ( empty( $more ) ) {
			$final = self::finalize_if_idle();
			return array_merge( $batch_counts, $final );
		}

		return array_merge(
			array(
				'success'  => true,
				'complete' => false,
				'progress' => self::get_progress(),
			),
			$batch_counts
		);
	}

	/**
	 * Migrate one post's legacy schema meta.
	 *
	 * @param int $post_id Post ID.
	 * @return array<string,int>
	 */
	public static function migrate_post( $post_id ) {
		$post_id = (int) $post_id;
		$counts  = array(
			'converted_ready'  => 0,
			'converted_draft'  => 0,
			'custom_valid'     => 0,
			'custom_invalid'   => 0,
			'skipped'          => 0,
		);

		if ( $post_id <= 0 || ! get_post( $post_id ) ) {
			++$counts['skipped'];
			return $counts;
		}

		if ( '1' === (string) get_post_meta( $post_id, self::META_POST_MIGRATED, true ) ) {
			++$counts['skipped'];
			return $counts;
		}

		$legacy = get_post_meta( $post_id, '_seovault_schemas', true );
		if ( is_array( $legacy ) && ! empty( $legacy['schemas'] ) && is_array( $legacy['schemas'] ) ) {
			$doc_counts = self::convert_legacy_schemas( $post_id, $legacy['schemas'] );
			$counts['converted_ready'] += $doc_counts['ready'];
			$counts['converted_draft'] += $doc_counts['draft'];
			// Remove legacy so incomplete entries cannot publish via the old path.
			delete_post_meta( $post_id, '_seovault_schemas' );
		} elseif ( ! empty( $legacy ) && ! is_array( $legacy ) ) {
			// Malformed meta: mark migrated, leave alone otherwise.
			++$counts['skipped'];
		}

		$custom = PostMeta::get_schema_json( $post_id );
		if ( '' !== trim( (string) $custom ) ) {
			$json_result = self::migrate_custom_json( $post_id, (string) $custom );
			if ( 'valid' === $json_result ) {
				++$counts['custom_valid'];
			} elseif ( 'invalid' === $json_result ) {
				++$counts['custom_invalid'];
			} else {
				++$counts['skipped'];
			}
		}

		update_post_meta( $post_id, self::META_POST_MIGRATED, '1' );
		SchemaValidationCache::delete_local( $post_id );

		return $counts;
	}

	/**
	 * Convert legacy per-post schema entries into V2 instances (idempotent by source id).
	 *
	 * @param int   $post_id Post ID.
	 * @param array $schemas Legacy schemas list.
	 * @return array{ready:int,draft:int}
	 */
	public static function convert_legacy_schemas( $post_id, array $schemas ) {
		$ready = 0;
		$draft = 0;
		$doc   = self::get_instances_document( $post_id );
		$by_source = array();
		foreach ( $doc['instances'] as $index => $instance ) {
			if ( ! is_array( $instance ) ) {
				continue;
			}
			$source = (string) ( $instance['migration']['source_id'] ?? '' );
			if ( '' !== $source ) {
				$by_source[ $source ] = $index;
			}
		}

		foreach ( $schemas as $index => $schema ) {
			if ( ! is_array( $schema ) ) {
				continue;
			}

			$type = (string) ( $schema['type'] ?? '' );
			if ( '' === $type ) {
				continue;
			}

			// Woo owns Product on product posts; do not create competing manual instances.
			if ( 'Product' === $type && 'product' === get_post_type( $post_id ) ) {
				continue;
			}

			$fields    = SchemaMigration::extract_field_values( $schema['fields'] ?? array() );
			$source_id = self::stable_source_id( $post_id, $index, $type );
			$now       = time();

			// Preserve prior source_meta on re-migrate; grandfather visibility for
			// types that now require confirm_visible so legacy emitters keep emitting.
			$source_meta = array();
			if ( isset( $by_source[ $source_id ] ) ) {
				$existing_meta = $doc['instances'][ $by_source[ $source_id ] ]['source_meta'] ?? null;
				if ( is_array( $existing_meta ) ) {
					$source_meta = $existing_meta;
				}
			}
			$policy = SchemaTypePolicyRegistry::get_entry( $type );
			if ( is_array( $policy ) && ! empty( $policy['requires_visible_content'] ) ) {
				$mode = isset( $source_meta['visibility_mode'] ) ? sanitize_key( (string) $source_meta['visibility_mode'] ) : '';
				if ( ! in_array( $mode, array( 'confirm_visible', 'seovault_block' ), true ) ) {
					$source_meta['visibility_mode'] = 'confirm_visible';
				}
			}

			$is_ready = SchemaReadinessEvaluator::evaluate(
				$type,
				$fields,
				array(
					'enforce_visibility' => true,
					'source_meta'        => $source_meta,
				)
			)->is_ready();
			$status = $is_ready ? 'ready' : 'draft';

			$instance = array(
				'id'         => self::stable_instance_id( $source_id ),
				'type'       => $type,
				'source'     => 'manual',
				'status'     => $status,
				'fields'     => $fields,
				'created_at' => $now,
				'updated_at' => $now,
				'migration'  => array(
					'source_id' => $source_id,
					'from'      => 'post_schemas',
				),
			);
			if ( ! empty( $source_meta ) ) {
				$instance['source_meta'] = $source_meta;
			}

			if ( isset( $by_source[ $source_id ] ) ) {
				$existing_index = $by_source[ $source_id ];
				$existing       = $doc['instances'][ $existing_index ];
				$instance['id']         = (string) ( $existing['id'] ?? $instance['id'] );
				$instance['created_at'] = (int) ( $existing['created_at'] ?? $now );
				$doc['instances'][ $existing_index ] = $instance;
			} else {
				$doc['instances'][] = $instance;
			}

			if ( $is_ready ) {
				++$ready;
			} else {
				++$draft;
			}
		}

		$doc['version'] = SchemaInstanceStorage::DOCUMENT_VERSION;
		// Migration writes skip capability checks; storage still sanitizes + recomputes readiness.
		SchemaInstanceStorage::write_document(
			$post_id,
			$doc,
			array(
				'skip_cap_check' => true,
			)
		);

		return array(
			'ready' => $ready,
			'draft' => $draft,
		);
	}

	/**
	 * Migrate custom JSON for one post.
	 *
	 * @param int    $post_id Post ID.
	 * @param string $custom  Raw JSON.
	 * @return string valid|invalid|skipped
	 */
	public static function migrate_custom_json( $post_id, $custom ) {
		$post_id = (int) $post_id;
		$custom  = (string) $custom;
		$decoded = json_decode( $custom, true );
		$valid   = ( JSON_ERROR_NONE === json_last_error() && is_array( $decoded ) );

		if ( $valid ) {
			// Legacy valid custom JSON keeps replace semantics until the user changes mode.
			SchemaCustomJson::mark_legacy_replace( $post_id );
			return 'valid';
		}

		SchemaCustomJson::withhold_invalid( $post_id, $custom );
		// Phase 11 cleared the live override; keep that behavior for migration batches.
		PostMeta::delete_schema_json( $post_id );
		return 'invalid';
	}

	/**
	 * Ready V2 instances as legacy-shaped schema configs for emission.
	 *
	 * Delegates to SchemaInstanceStorage which recomputes readiness and never
	 * trusts stored client/migration status alone.
	 *
	 * @param int $post_id Post ID.
	 * @return array
	 */
	public static function get_ready_schema_configs( $post_id ) {
		return SchemaInstanceStorage::get_ready_schema_configs( $post_id );
	}

	/**
	 * Whether a post has completed per-post V2 migration.
	 *
	 * @param int $post_id Post ID.
	 * @return bool
	 */
	public static function is_post_migrated( $post_id ) {
		return '1' === (string) get_post_meta( (int) $post_id, self::META_POST_MIGRATED, true );
	}

	/**
	 * Custom JSON mode for a post.
	 *
	 * @param int $post_id Post ID.
	 * @return string
	 */
	public static function get_json_mode( $post_id ) {
		return (string) get_post_meta( (int) $post_id, self::META_JSON_MODE, true );
	}

	/**
	 * Stable source id for a legacy entry.
	 *
	 * @param int    $post_id Post ID.
	 * @param int    $index   Entry index.
	 * @param string $type    Schema type.
	 * @return string
	 */
	public static function stable_source_id( $post_id, $index, $type ) {
		return 'legacy-post-' . (int) $post_id . '-' . (int) $index . '-' . sanitize_key( (string) $type );
	}

	/**
	 * Stable instance UUID-like id from source id.
	 *
	 * @param string $source_id Source id.
	 * @return string
	 */
	public static function stable_instance_id( $source_id ) {
		return 'sv2-post-' . substr( hash( 'sha256', (string) $source_id ), 0, 20 );
	}

	/**
	 * Reset progress + markers (tests).
	 *
	 * @param int[] $post_ids Optional posts to clear meta on.
	 * @return void
	 */
	public static function reset_state( array $post_ids = array() ) {
		delete_option( 'seovaultseo_' . self::OPTION_PROGRESS );
		foreach ( $post_ids as $post_id ) {
			$post_id = (int) $post_id;
			if ( $post_id <= 0 ) {
				continue;
			}
			delete_post_meta( $post_id, self::META_INSTANCES );
			delete_post_meta( $post_id, self::META_JSON_MODE );
			delete_post_meta( $post_id, self::META_JSON_INVALID_BACKUP );
			delete_post_meta( $post_id, self::META_POST_MIGRATED );
			delete_post_meta( $post_id, SchemaCustomJson::META_DOCUMENT );
			delete_post_meta( $post_id, SchemaCustomJson::META_LAST_VALID );
		}
	}

	/**
	 * Default progress structure.
	 *
	 * @return array<string,mixed>
	 */
	private static function default_progress() {
		return array(
			'status'           => 'pending',
			'last_post_id'     => 0,
			'processed'        => 0,
			'converted_ready'  => 0,
			'converted_draft'  => 0,
			'custom_valid'     => 0,
			'custom_invalid'   => 0,
			'skipped'          => 0,
			'batch_size'       => self::BATCH_SIZE,
		);
	}

	/**
	 * Persist progress.
	 *
	 * @param array $progress Progress.
	 * @return void
	 */
	private static function save_progress( array $progress ) {
		$option = 'seovaultseo_' . self::OPTION_PROGRESS;
		if ( false === get_option( $option, false ) ) {
			add_option( $option, $progress, '', false );
		} else {
			update_option( $option, $progress, false );
		}
	}

	/**
	 * Mark complete when no eligible posts remain; bump generation once.
	 *
	 * @return array<string,mixed>
	 */
	private static function finalize_if_idle() {
		$progress = self::get_progress();
		if ( 'complete' === (string) ( $progress['status'] ?? '' ) ) {
			return array(
				'success'            => true,
				'complete'           => true,
				'processed_in_batch' => 0,
				'progress'           => $progress,
			);
		}

		// Recheck from the beginning in case posts were inserted behind the cursor.
		$remaining = self::query_batch_post_ids( 0 );
		if ( ! empty( $remaining ) ) {
			$progress['last_post_id'] = 0;
			$progress['status']       = 'running';
			self::save_progress( $progress );
			return array(
				'success'            => true,
				'complete'           => false,
				'processed_in_batch' => 0,
				'progress'           => $progress,
			);
		}

		$progress['status'] = 'complete';
		self::save_progress( $progress );
		SchemaValidationCache::bump_generation();

		return array(
			'success'            => true,
			'complete'           => true,
			'processed_in_batch' => 0,
			'progress'           => $progress,
		);
	}

	/**
	 * Cursor for ID pagination (posts_where).
	 *
	 * @var int
	 */
	private static $query_after_id = 0;

	/**
	 * Restrict migration queries to posts with ID greater than the cursor.
	 *
	 * @param string $where WHERE clause.
	 * @return string
	 */
	public static function filter_posts_where_after_id( $where ) {
		global $wpdb;
		if ( self::$query_after_id > 0 ) {
			$where .= $wpdb->prepare( " AND {$wpdb->posts}.ID > %d", self::$query_after_id );
		}
		return $where;
	}

	/**
	 * Query next post IDs needing migration.
	 *
	 * @param int $after_id After this post ID.
	 * @return int[]
	 */
	private static function query_batch_post_ids( $after_id = 0 ) {
		$after_id = max( 0, (int) $after_id );
		$args     = array(
			'post_type'              => 'any',
			'post_status'            => array( 'publish', 'draft', 'pending', 'private', 'future' ),
			'posts_per_page'         => self::BATCH_SIZE,
			'orderby'                => 'ID',
			'order'                  => 'ASC',
			'fields'                 => 'ids',
			'no_found_rows'          => true,
			'update_post_meta_cache' => false,
			'update_post_term_cache' => false,
			'meta_query'             => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- One-time migration batch query.
				'relation' => 'AND',
				array(
					'relation' => 'OR',
					array(
						'key'     => '_seovault_schemas',
						'compare' => 'EXISTS',
					),
					array(
						'key'     => '_seovault_schema_json',
						'compare' => 'EXISTS',
					),
				),
				array(
					'relation' => 'OR',
					array(
						'key'     => self::META_POST_MIGRATED,
						'compare' => 'NOT EXISTS',
					),
					array(
						'key'     => self::META_POST_MIGRATED,
						'value'   => '1',
						'compare' => '!=',
					),
				),
			),
		);

		self::$query_after_id = $after_id;
		if ( $after_id > 0 ) {
			add_filter( 'posts_where', array( __CLASS__, 'filter_posts_where_after_id' ) );
		}
		$posts = get_posts( $args );
		if ( $after_id > 0 ) {
			remove_filter( 'posts_where', array( __CLASS__, 'filter_posts_where_after_id' ) );
		}
		self::$query_after_id = 0;

		return array_map( 'intval', $posts );
	}

	/**
	 * Load or create instances document (migration-compatible reader).
	 *
	 * @param int $post_id Post ID.
	 * @return array{version:int,instances:array}
	 */
	private static function get_instances_document( $post_id ) {
		return SchemaInstanceStorage::read_document( $post_id );
	}
}
