<?php

namespace Fotogenic_AI;

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- One versioned cutover verifies and migrates only plugin-owned tables, options, caches, and attachment metadata.

/**
 * One-time persisted-data cutover for the Universal Studio-only runtime.
 */
final class Universal_Data_Migration {
	public const SCHEMA_VERSION = 2;
	public const VERSION_OPTION = 'fotogenic_ai_universal_data_schema_version';
	public const JOURNAL_OPTION = 'fotogenic_ai_universal_data_migration_journal';
	private const LOCK_OPTION = 'fotogenic_ai_universal_data_migration_lock';
	private const LOCK_TTL = 300;
	private const BATCH_SIZE = 250;
	private const WORKFLOWS = [ 'product_images', 'campaign_set', 'increase_resolution' ];
	private const SOURCES = [ 'product', 'media', 'post', 'page' ];
	private const RETIRED_LOGGER_RUN_COLUMNS = [
		'duration_ms',
		'environment',
		'selections_snapshot',
		'custom_instructions',
		'subject_image_id',
		'inspiration_image_id',
		'warn_count',
		'error_count',
		'last_finalize_attempt_at',
		'finalize_started_at',
		'finalize_last_error',
	];
	private static ?Universal_Data_Migration $instance = null;
	private ?bool $ready = null;
	private string $error_code = '';

	private function __construct() {}

	public static function instance(): Universal_Data_Migration {
		if ( null === self::$instance ) {
			self::$instance = new self();
		}

		return self::$instance;
	}

	public function register(): void {
		add_action( 'init', [ $this, 'run' ], 2 );
	}

	public function is_ready(): bool {
		return true === $this->ready;
	}

	public function error_code(): string {
		return $this->error_code;
	}

	public function readiness_error(): \WP_Error {
		return new \WP_Error(
			'fotogenic_ai_universal_data_unavailable',
			__( 'Fotogenic Studio data is being upgraded or requires administrator attention.', 'fotogenic-ai' ),
			[
				'status' => 503,
				'reason' => $this->error_code,
			]
		);
	}

	/**
	 * Run the deterministic cutover. No method in this class starts an upstream job.
	 */
	public function run( bool $force = false ): bool {
		if ( true === $this->ready && ! $force ) {
			return true;
		}
		$this->ready      = false;
		$this->error_code = '';
		$stored_version   = (int) get_option( self::VERSION_OPTION, 0 );
		if ( $stored_version > self::SCHEMA_VERSION ) {
			return $this->fail( 'future_central_schema', 'version' );
		}
		if ( ! $force && self::SCHEMA_VERSION === $stored_version && $this->verify_current_state() ) {
			$this->ready = true;
			return true;
		}

		$token = $this->acquire_lock();
		if ( '' === $token ) {
			return $this->fail( 'migration_locked', 'lock' );
		}

		$started_at = time();
		$counts     = [];
		$step       = 'schemas';
		$this->write_journal( 'running', $step, $started_at, $counts );
		try {
			if ( ! $this->component_versions_supported() ) {
				return $this->fail( 'future_component_schema', 'schemas', $started_at, $counts );
			}
			$bulk_repo = Bulk_Repo::instance();
			if ( ! $bulk_repo->prepare_schema_for_migration() ) {
				$storage = $bulk_repo->transactional_storage_status();
				$code = 'transactional_storage_required' === $storage['code']
					? 'transactional_storage_required'
					: 'schema_unavailable';
				return $this->fail( $code, 'schemas', $started_at, $counts );
			}
			if ( ! Store_Queue_Repo::instance()->prepare_schema_for_migration() ) {
				return $this->fail( 'store_queue_schema_unavailable', 'schemas', $started_at, $counts );
			}
			Logger::instance()->ensure_schema();
			Presets::instance()->ensure_schema();
			$logger_schema = $this->migrate_logger_schema();
			if ( is_wp_error( $logger_schema ) || ! Logger::instance()->ensure_schema() ) {
				$code = is_wp_error( $logger_schema ) ? sanitize_key( $logger_schema->get_error_code() ) : 'schema_unavailable';
				return $this->fail( $code, 'schemas', $started_at, $counts );
			}
			$counts['logger_schema'] = $logger_schema;

			$step = 'runtime_state';
			$this->write_journal( 'running', $step, $started_at, $counts );
			$runtime = $this->migrate_runtime_state();
			if ( is_wp_error( $runtime ) ) {
				return $this->fail( sanitize_key( $runtime->get_error_code() ), 'runtime_state', $started_at, $counts );
			}
			$counts['runtime'] = $runtime;

			$step = 'bulk';
			$this->write_journal( 'running', $step, $started_at, $counts );
			$bulk = $this->migrate_bulk_rows();
			if ( is_wp_error( $bulk ) || ! $this->finalize_bulk_schema_cutover() || ! Bulk_Repo::instance()->ensure_tables() || ! Store_Queue_Repo::instance()->ensure_tables() ) {
				$code = is_wp_error( $bulk ) ? sanitize_key( $bulk->get_error_code() ) : 'bulk_schema_unavailable';
				return $this->fail( $code, 'bulk', $started_at, $counts );
			}
			$counts['bulk'] = $bulk;

			$step = 'runs_and_attachments';
			$this->write_journal( 'running', $step, $started_at, $counts );
			$runs = $this->migrate_runs_and_attachments();
			if ( is_wp_error( $runs ) ) {
				return $this->fail( sanitize_key( $runs->get_error_code() ), 'runs_and_attachments', $started_at, $counts );
			}
			$counts['runs'] = $runs;

			$step = 'presets';
			$this->write_journal( 'running', $step, $started_at, $counts );
			$presets = $this->migrate_presets();
			if ( is_wp_error( $presets ) || ! Presets::instance()->ensure_schema() ) {
				$code = is_wp_error( $presets ) ? sanitize_key( $presets->get_error_code() ) : 'preset_schema_unavailable';
				return $this->fail( $code, 'presets', $started_at, $counts );
			}
			$counts['presets'] = $presets;

			$step = 'verification';
			$this->write_journal( 'running', $step, $started_at, $counts );
			if ( ! $this->verify_current_state() ) {
				return $this->fail( 'postcondition_failed', 'verification', $started_at, $counts );
			}
			if ( ! update_option( self::VERSION_OPTION, self::SCHEMA_VERSION, false )
				&& self::SCHEMA_VERSION !== (int) get_option( self::VERSION_OPTION, 0 ) ) {
				return $this->fail( 'version_persist_failed', 'verification', $started_at, $counts );
			}

			$this->write_journal( 'complete', 'complete', $started_at, $counts );
			$this->ready = true;
			return true;
		} catch ( \Throwable $error ) {
			return $this->fail( 'migration_exception', $step, $started_at, $counts );
		} finally {
			$this->release_lock( $token );
		}
	}

	private function component_versions_supported(): bool {
		return (int) get_option( 'fotogenic_ai_bulk_schema_version', 0 ) <= Bulk_Repo::SCHEMA_VERSION
			&& (int) get_option( 'fotogenic_ai_store_queue_schema_version', 0 ) <= Store_Queue_Repo::SCHEMA_VERSION
			&& (int) get_option( 'fotogenic_ai_logger_schema_version', 0 ) <= Logger::SCHEMA_VERSION
			&& (int) get_option( 'fotogenic_ai_presets_schema_version', 0 ) <= Presets::SCHEMA_VERSION;
	}

	/** @return array{backfilled: int, diagnostic_column_dropped: int, retired_columns_dropped: int}|\WP_Error */
	private function migrate_logger_schema() {
		global $wpdb;
		$table      = $wpdb->prefix . 'fotogenic_ai_runs';
		$logs_table = $wpdb->prefix . 'fotogenic_ai_logs';
		foreach ( [ 'run_target', 'subject_id' ] as $column ) {
			if ( $column !== (string) $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $column ) ) ) {
				return new \WP_Error( 'logger_identity_schema_unavailable' );
			}
		}
		$backfilled = 0;
		if ( 'product_id' === (string) $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'product_id' ) ) ) {
			$updated = $wpdb->query(
				"UPDATE {$table} SET run_target = 'product', subject_id = product_id
				WHERE product_id > 0 AND subject_id <= 0 AND (run_target IS NULL OR run_target IN ('', 'unknown', 'product'))"
			);
			if ( false === $updated
				|| ! $this->drop_index_if_present( $table, 'product_id_started' )
				|| ! $this->drop_column_if_present( $table, 'product_id' ) ) {
				return new \WP_Error( 'logger_identity_cutover_failed' );
			}
			$backfilled = (int) $updated;
		}

		$diagnostic_column_dropped = 'product_id' === (string) $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$logs_table} LIKE %s", 'product_id' ) );
		if ( $diagnostic_column_dropped && ! $this->drop_column_if_present( $logs_table, 'product_id' ) ) {
			return new \WP_Error( 'logger_diagnostic_cutover_failed' );
		}
		$retired_columns_dropped = 0;
		foreach ( self::RETIRED_LOGGER_RUN_COLUMNS as $column ) {
			$present = $column === (string) $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $column ) );
			if ( $present && ! $this->drop_column_if_present( $table, $column ) ) {
				return new \WP_Error( 'logger_run_schema_cutover_failed' );
			}
			$retired_columns_dropped += (int) $present;
		}

		return [
			'backfilled'                => $backfilled,
			'diagnostic_column_dropped' => (int) $diagnostic_column_dropped,
			'retired_columns_dropped'   => $retired_columns_dropped,
		];
	}

	/** @return array|\WP_Error */
	private function migrate_runtime_state() {
		$settings_changed = $this->migrate_settings();
		if ( is_wp_error( $settings_changed ) ) {
			return $settings_changed;
		}
		$deleted_caches = $this->delete_obsolete_caches();
		if ( false === $deleted_caches ) {
			return new \WP_Error( 'cache_cutover_failed' );
		}
		$locks = $this->migrate_lock_records();
		if ( is_wp_error( $locks ) ) {
			return $locks;
		}
		$reconcile_state_updated = $this->migrate_reconcile_cursor();
		if ( is_wp_error( $reconcile_state_updated ) ) {
			return $reconcile_state_updated;
		}
		$session = get_option( Auth_Session::OPTION_NAME, [] );
		$auth_reconnect_required = is_array( $session )
			&& '' !== trim( (string) ( $session['refreshToken'] ?? '' ) )
			&& '' === sanitize_key( (string) ( $session['userUid'] ?? '' ) );
		if ( $auth_reconnect_required ) {
			delete_option( Auth_Session::OPTION_NAME );
			wp_unschedule_hook( Auth_Session::CRON_HOOK );
		}

		return [
			'settings_updated'       => $settings_changed ? 1 : 0,
			'cache_rows_deleted'      => $deleted_caches,
			'locks_normalized'        => $locks['normalized'],
			'locks_expired'           => $locks['expired'],
			'reconcile_state_updated' => $reconcile_state_updated ? 1 : 0,
			'auth_reconnect_required' => $auth_reconnect_required ? 1 : 0,
		];
	}

	/** @return bool|\WP_Error */
	private function migrate_settings() {
		$option_name = Settings::instance()->get_option_name();
		$options     = get_option( $option_name, [] );
		if ( ! is_array( $options ) ) {
			return false;
		}
		$updated = $options;
		foreach ( [ 'api_key', 'api_key_length', 'api_key_last6' ] as $key ) {
			unset( $updated[ $key ] );
		}
		if ( array_key_exists( 'api_base_path', $updated ) ) {
			$updated['api_base_path'] = $this->normalize_base_path_for_cutover( (string) $updated['api_base_path'] );
		}
		if ( isset( $updated['api_endpoints'] ) && is_array( $updated['api_endpoints'] ) ) {
			$updated['api_endpoints'] = $this->normalize_endpoints_for_cutover( $updated['api_endpoints'] );
		}
		if ( $updated === $options ) {
			return false;
		}
		if ( ! update_option( $option_name, $updated, false ) && $updated !== get_option( $option_name, [] ) ) {
			return new \WP_Error( 'settings_cutover_failed' );
		}

		return true;
	}

	/** @return int|false */
	private function delete_obsolete_caches() {
		global $wpdb;
		$patterns = [
			'_transient_fotogenic_ai_credits_%',
			'_transient_timeout_fotogenic_ai_credits_%',
			'_transient_fotogenic_ai_config_%',
			'_transient_timeout_fotogenic_ai_config_%',
			'_transient_fotogenic_ai_price_tiers_%',
			'_transient_timeout_fotogenic_ai_price_tiers_%',
			'_transient_fotogenic_ai_job_lock_%',
			'_transient_timeout_fotogenic_ai_job_lock_%',
			'_transient_fotogenic_ai_pending_run_%',
			'_transient_timeout_fotogenic_ai_pending_run_%',
		];
		$where  = implode( ' OR ', array_fill( 0, count( $patterns ), 'option_name LIKE %s' ) );
		$params = array_map( static fn( string $pattern ): string => $wpdb->esc_like( rtrim( $pattern, '%' ) ) . '%', $patterns );
		// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The bounded placeholder fragment and argument list are generated from the same private prefix list above.
		$option_names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE {$where}", ...$params ) );
		if ( ! is_array( $option_names ) ) {
			return false;
		}
		// phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare -- The bounded placeholder fragment and argument list are generated from the same private prefix list above.
		$deleted = $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->options} WHERE {$where}", ...$params ) );
		if ( false !== $deleted ) {
			foreach ( $option_names as $option_name ) {
				$this->clear_option_cache( (string) $option_name );
			}
		}

		return is_int( $deleted ) ? $deleted : false;
	}

	/** @return array{normalized: int, expired: int}|\WP_Error */
	private function migrate_lock_records() {
		global $wpdb;
		$rows = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT option_name, option_value FROM {$wpdb->options}
				WHERE option_name LIKE %s OR option_name LIKE %s OR option_name IN (%s, %s, %s)",
				$wpdb->esc_like( 'fotogenic_ai_finalize_lock_' ) . '%',
				$wpdb->esc_like( 'fotogenic_ai_experience_lock_' ) . '%',
				'fotogenic_ai_auth_refresh_lock',
				'fotogenic_ai_bulk_housekeeping_lock',
				'fotogenic_ai_log_purge_lock'
			),
			ARRAY_A
		);
		if ( ! is_array( $rows ) ) {
			return new \WP_Error( 'lock_cutover_failed' );
		}
		$counts = [
			'normalized' => 0,
			'expired'    => 0,
		];
		foreach ( $rows as $row ) {
			$option_name = (string) ( $row['option_name'] ?? '' );
			$serialized  = (string) ( $row['option_value'] ?? '' );
			$value       = maybe_unserialize( $serialized );
			$token       = is_array( $value ) ? trim( (string) ( $value['token'] ?? '' ) ) : '';
			$expires_at  = is_array( $value ) ? (int) ( $value['expires_at'] ?? 0 ) : 0;
			if ( $expires_at <= 0 && is_array( $value ) ) {
				$created_at = (int) ( $value['created_at'] ?? $value['createdAt'] ?? 0 );
				$expires_at = $created_at > 0 ? $created_at + HOUR_IN_SECONDS : 0;
			}
			if ( '' === $token || $expires_at <= time() ) {
				$deleted = $wpdb->query(
					$wpdb->prepare(
						"DELETE FROM {$wpdb->options} WHERE option_name = %s AND BINARY option_value = BINARY %s",
						$option_name,
						$serialized
					)
				);
				if ( false === $deleted ) {
					return new \WP_Error( 'lock_cutover_failed' );
				}
				$counts['expired'] += (int) $deleted;
				$this->clear_option_cache( $option_name );
				continue;
			}
			$updated = [
				'token'      => $token,
				'expires_at' => $expires_at,
			];
			if ( $updated === $value ) {
				continue;
			}
			$changed = $wpdb->query(
				$wpdb->prepare(
					"UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND BINARY option_value = BINARY %s",
					maybe_serialize( $updated ),
					$option_name,
					$serialized
				)
			);
			if ( false === $changed ) {
				return new \WP_Error( 'lock_cutover_failed' );
			}
			$counts['normalized'] += (int) $changed;
			$this->clear_option_cache( $option_name );
		}

		return $counts;
	}

	/** @return bool|\WP_Error */
	private function migrate_reconcile_cursor() {
		$option_name = 'fotogenic_ai_finalize_reconcile_state';
		$state       = get_option( $option_name, [] );
		if ( ! is_array( $state ) || ! array_key_exists( 'cursor', $state ) ) {
			return false;
		}
		if ( ! array_key_exists( 'run_cursor', $state ) ) {
			$state['run_cursor'] = max( 0, (int) $state['cursor'] );
		}
		unset( $state['cursor'] );
		if ( ! update_option( $option_name, $state, false ) && $state !== get_option( $option_name, [] ) ) {
			return new \WP_Error( 'reconcile_cursor_cutover_failed' );
		}

		return true;
	}

	/** @return array|\WP_Error */
	private function migrate_bulk_rows() {
		global $wpdb;
		$runs_table  = $wpdb->prefix . 'fotogenic_ai_bulk_runs';
		$items_table = $wpdb->prefix . 'fotogenic_ai_bulk_run_items';
		$cursor      = 0;
		$counts      = [
			'items'              => 0,
			'identities_added'   => 0,
			'accepted_delegated' => 0,
			'quarantined'        => 0,
			'statuses_reclassified' => 0,
			'runs_paused'        => 0,
		];
		$quarantine_runs = [];
		$seen_item_ids = [];
		$seen_generation_keys = [];
		do {
			$rows = $wpdb->get_results(
				$wpdb->prepare(
					"SELECT i.*, r.run_uuid, r.user_id, r.workflow, r.status AS run_status
					FROM {$items_table} i INNER JOIN {$runs_table} r ON r.id = i.run_id
					WHERE i.id > %d ORDER BY i.id ASC LIMIT %d",
					$cursor,
					self::BATCH_SIZE
				),
				ARRAY_A
			);
			if ( ! is_array( $rows ) ) {
				return new \WP_Error( 'bulk_cutover_read_failed' );
			}
			foreach ( $rows as $row ) {
				$id     = absint( $row['id'] ?? 0 );
				$cursor = max( $cursor, $id );
				if ( $id <= 0 ) {
					continue;
				}
				++$counts['items'];
				$run_uuid = strtolower( trim( (string) ( $row['run_uuid'] ?? '' ) ) );
				$item_uuid = strtolower( trim( (string) ( $row['run_item_uuid'] ?? '' ) ) );
				$run_id = absint( $row['run_id'] ?? 0 );
				if ( ! wp_is_uuid( $item_uuid ) || isset( $seen_item_ids[ $run_id ][ $item_uuid ] ) ) {
					$item_uuid = Workflow_Item_Identity::deterministic_uuid( 'fotogenic-ai-universal-item', $run_uuid, (string) $id );
				}
				$seen_item_ids[ $run_id ][ $item_uuid ] = true;
				$changes = [];
				if ( $item_uuid !== (string) ( $row['run_item_uuid'] ?? '' ) ) {
					$changes['run_item_uuid'] = $item_uuid;
					++$counts['identities_added'];
				}
				$workflow = sanitize_key( (string) ( $row['workflow'] ?? '' ) );
				if ( 'campaign_set' === $workflow && ! wp_is_uuid( (string) ( $row['campaign_group_uuid'] ?? '' ) ) ) {
					$changes['campaign_group_uuid'] = Workflow_Item_Identity::deterministic_uuid( 'fotogenic-ai-universal-group', $run_uuid, (string) $id );
				}
				if ( (int) ( $row['item_ordinal'] ?? 0 ) <= 0 ) {
					$changes['item_ordinal'] = $id;
				}
				$subject_type = sanitize_key( (string) ( $row['subject_type'] ?? '' ) );
				$subject_id   = absint( $row['subject_id'] ?? 0 );
				if ( ! in_array( $subject_type, self::SOURCES, true ) && absint( $row['product_id'] ?? 0 ) > 0 ) {
					$subject_type = 'product';
					$changes['subject_type'] = 'product';
				}
				if ( $subject_id <= 0 && 'product' === $subject_type ) {
					$subject_id = absint( $row['product_id'] ?? 0 );
					$changes['subject_id'] = $subject_id;
				}
				$target = $this->decode_target( $row['target_snapshot'] ?? '' );
				if ( is_array( $target ) ) {
					$changes['target_type']      = (string) $target['targetType'];
					$changes['parent_product_id']= (int) $target['parentProductId'];
					$changes['variation_id']     = (int) $target['variationId'];
				} elseif ( in_array( sanitize_key( (string) ( $row['target_type'] ?? '' ) ), [ '', 'legacy' ], true ) ) {
					$changes['target_type'] = 'unknown';
				}
				$generation_key = strtolower( trim( (string) ( $row['generation_key'] ?? '' ) ) );
				if ( 1 !== preg_match( '/^[a-f0-9]{40}$/', $generation_key ) || isset( $seen_generation_keys[ $run_id ][ $generation_key ] ) ) {
					$generation_key = sha1( $run_uuid . '|' . $id );
					$changes['generation_key'] = $generation_key;
				}
				$seen_generation_keys[ $run_id ][ $generation_key ] = true;
				$meta = $this->decode_meta( $row['meta'] ?? '' );
				if ( isset( $meta['migrationAcceptedRecovery'] ) || isset( $meta['migrationAcceptedFinalizer'] ) ) {
					unset( $meta['migrationAcceptedRecovery'], $meta['migrationAcceptedFinalizer'] );
					$changes['meta'] = wp_json_encode( $meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
				}

				$effective = array_merge( $row, $changes );
				$jobs      = $this->decode_job_ids( $row['job_ids'] ?? [] );
				$status    = sanitize_key( (string) ( $row['status'] ?? '' ) );
				$stored_status = $status;
				$reclassified = $this->classify_retired_item_status( $status, $effective );
				if ( null !== $reclassified ) {
					$changes['status'] = $reclassified;
					$status            = $reclassified;
					if ( '' === trim( (string) ( $row['finished_at'] ?? '' ) ) ) {
						$changes['finished_at'] = current_time( 'mysql', true );
					}
					$changes['claim_owner']      = null;
					$changes['claim_expires_at'] = null;
					++$counts['statuses_reclassified'];
					if ( 'applying' === $stored_status && 'failed' === $reclassified ) {
						$changes['error_code']    = 'MIGRATION_SIDE_EFFECT_UNVERIFIABLE';
						$changes['error_message'] = __( 'The previous apply operation could not be proven complete. No automatic retry will occur.', 'fotogenic-ai' );
						$quarantine_runs[ absint( $row['run_id'] ?? 0 ) ] = absint( $row['user_id'] ?? 0 );
						++$counts['quarantined'];
					}
				}
				$current_command = $this->bulk_command_is_current( $effective );
				if ( ! empty( $jobs ) && in_array( $status, [ 'pending', 'starting', 'generating' ], true ) ) {
					$recovery = $this->prepare_accepted_bulk_recovery( $effective, $jobs );
					if ( is_wp_error( $recovery ) ) {
						$changes['status']        = 'failed';
						$changes['error_code']    = 'MIGRATION_ACCEPTED_RECOVERY_CONFLICT';
						$changes['error_message'] = __( 'This accepted job could not be linked to a verified finalization identity. It will never be restarted automatically.', 'fotogenic-ai' );
						$quarantine_runs[ absint( $row['run_id'] ?? 0 ) ] = absint( $row['user_id'] ?? 0 );
						++$counts['quarantined'];
					} else {
						$changes['run_id_single'] = $recovery['finalizerRunId'];
						$changes['request_id']    = $recovery['requestId'];
						$changes['status']        = 'uncertain';
						$changes['error_code']    = 'MIGRATION_FINALIZER_DELEGATED';
						$changes['error_message'] = __( 'The accepted job is being finalized from its verified run record and will not be restarted.', 'fotogenic-ai' );
						++$counts['accepted_delegated'];
						$changes['meta'] = wp_json_encode( $meta, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE );
					}
					$changes['claim_owner']      = null;
					$changes['claim_expires_at'] = null;
				} elseif ( ( in_array( $status, [ 'pending', 'starting' ], true ) && ! $current_command ) || ( 'generating' === $status && empty( $jobs ) ) ) {
					$changes['status']           = 'failed';
					$changes['error_code']       = 'MIGRATION_COMMAND_UNVERIFIABLE';
					$changes['error_message']    = __( 'This stored item cannot be restarted safely because its immutable billing command could not be verified.', 'fotogenic-ai' );
					$changes['claim_owner']      = null;
					$changes['claim_expires_at'] = null;
					$quarantine_runs[ absint( $row['run_id'] ?? 0 ) ] = absint( $row['user_id'] ?? 0 );
					++$counts['quarantined'];
				}
				if ( ! empty( $changes ) && false === $wpdb->update( $items_table, $changes, [ 'id' => $id ] ) ) {
					return new \WP_Error( 'bulk_cutover_write_failed' );
				}
			}
		} while ( count( $rows ) === self::BATCH_SIZE );

		foreach ( $quarantine_runs as $run_id => $user_id ) {
			$changed = $wpdb->query(
				$wpdb->prepare(
					"UPDATE {$runs_table} SET status = %s, status_reason = %s, last_error = %s, updated_at = %s,
					lock_owner = NULL, lock_expires_at = NULL
					WHERE id = %d AND user_id = %d AND status IN ('created', 'admitting', 'running', 'pausing', 'paused')",
					'paused',
					'migration_recovery_required',
					__( 'One or more stored items could not be verified and will not be restarted automatically.', 'fotogenic-ai' ),
					current_time( 'mysql', true ),
					$run_id,
					$user_id
				)
			);
			if ( false === $changed ) {
				return new \WP_Error( 'bulk_cutover_write_failed' );
			}
			$counts['runs_paused'] += (int) $changed;
		}

		return $counts;
	}

	private function finalize_bulk_schema_cutover(): bool {
		global $wpdb;
		$runs_table  = $wpdb->prefix . 'fotogenic_ai_bulk_runs';
		$items_table = $wpdb->prefix . 'fotogenic_ai_bulk_run_items';
		foreach ( [ 'run_subject', 'run_product', 'product_id', 'run_decision_replay', 'run_decision_idempotency' ] as $index ) {
			if ( ! $this->drop_index_if_present( $items_table, $index ) ) {
				return false;
			}
		}
		foreach ( [ 'product_id', 'decision_at', 'decision_action', 'decision_idempotency' ] as $column ) {
			if ( ! $this->drop_column_if_present( $items_table, $column ) ) {
				return false;
			}
		}
		foreach ( [ 'accepted_count', 'declined_count', 'action_mode', 'stop_on_low_credits', 'credits_estimate', 'credits_spent' ] as $column ) {
			if ( ! $this->drop_column_if_present( $runs_table, $column ) ) {
				return false;
			}
		}

		return $this->ensure_unique_index( $items_table, 'run_item', [ 'run_id', 'run_item_uuid' ] )
			&& $this->ensure_unique_index( $items_table, 'run_generation_key', [ 'run_id', 'generation_key' ] );
	}

	private function ensure_unique_index( string $table, string $index, array $columns ): bool {
		global $wpdb;
		$definition = $wpdb->get_results( $wpdb->prepare( "SHOW INDEX FROM {$table} WHERE Key_name = %s", $index ), ARRAY_A );
		if ( ! empty( $definition ) ) {
			usort( $definition, static fn( array $left, array $right ): int => (int) $left['Seq_in_index'] <=> (int) $right['Seq_in_index'] );
			$stored_columns = array_map( static fn( array $row ): string => (string) ( $row['Column_name'] ?? '' ), $definition );
			if ( $columns === $stored_columns && 0 === (int) ( $definition[0]['Non_unique'] ?? 1 ) ) {
				return true;
			}
			if ( ! $this->drop_index_if_present( $table, $index ) ) {
				return false;
			}
		}

		$identity_column = (string) end( $columns );
		$invalid = $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE `{$identity_column}` IS NULL OR `{$identity_column}` = ''" );
		$column_list = implode( ', ', array_map( static fn( string $column ): string => "`{$column}`", $columns ) );
		$duplicates = $wpdb->get_var(
			"SELECT COUNT(*) FROM (
				SELECT {$column_list} FROM {$table}
				GROUP BY {$column_list} HAVING COUNT(*) > 1
			) duplicate_identities"
		);
		if ( null === $invalid || null === $duplicates || 0 !== (int) $invalid || 0 !== (int) $duplicates ) {
			return false;
		}
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- Private plugin-owned identifiers are selected from the exact current schema contract above.
		return false !== $wpdb->query( "ALTER TABLE {$table} ADD UNIQUE KEY `{$index}` ({$column_list})" );
	}

	private function drop_index_if_present( string $table, string $index ): bool {
		global $wpdb;
		$found = $wpdb->get_var( $wpdb->prepare( "SHOW INDEX FROM {$table} WHERE Key_name = %s", $index ) );
		if ( null === $found ) {
			return true;
		}
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- Table and index are selected from private plugin-owned cutover lists.
		return false !== $wpdb->query( "ALTER TABLE {$table} DROP INDEX `{$index}`" );
	}

	private function drop_column_if_present( string $table, string $column ): bool {
		global $wpdb;
		$found = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $column ) );
		if ( $column !== (string) $found ) {
			return true;
		}
		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.SchemaChange -- Table and column are selected from private plugin-owned cutover lists.
		return false !== $wpdb->query( "ALTER TABLE {$table} DROP COLUMN `{$column}`" );
	}

	private function classify_retired_item_status( string $status, array $row ): ?string {
		switch ( $status ) {
			case 'ready':
			case 'accepted':
				return 'completed';
			case 'declined':
				return 'skipped';
			case 'applying':
				return 'failed';
			case 'failed_apply':
				return absint( $row['attachment_id'] ?? 0 ) > 0 || ! empty( $this->decode_job_ids( $row['attachment_ids'] ?? [] ) )
					? 'partial'
					: 'failed';
		}

		return null;
	}

	private function bulk_command_is_current( array $row ): bool {
		$command = json_decode( trim( (string) ( $row['command_snapshot'] ?? '' ) ), true );
		if ( ! is_array( $command ) || [ 'schemaVersion', 'runItem', 'draft', 'sources' ] !== array_keys( $command ) || 2 !== (int) $command['schemaVersion'] ) {
			return false;
		}
		$run_item = is_array( $command['runItem'] ?? null ) ? $command['runItem'] : [];
		$draft    = is_array( $command['draft'] ?? null ) ? $command['draft'] : [];
		$sources  = is_array( $command['sources'] ?? null ) ? $command['sources'] : [];
		if (
			[ 'id', 'groupId', 'ordinal', 'target', 'sourceRoles', 'destination' ] !== array_keys( $run_item )
			|| ! wp_is_uuid( (string) ( $row['run_item_uuid'] ?? '' ) )
			|| ! hash_equals( strtolower( (string) $row['run_item_uuid'] ), strtolower( (string) ( $run_item['id'] ?? '' ) ) )
			|| ! wp_is_uuid( strtolower( trim( (string) ( $row['request_id'] ?? '' ) ) ) )
			|| 'bulk' !== (string) ( $draft['scope'] ?? '' )
			|| (string) ( $row['workflow'] ?? '' ) !== (string) ( $draft['workflow'] ?? '' )
			|| (string) ( $row['subject_type'] ?? '' ) !== (string) ( $draft['sourceType'] ?? '' )
			|| (int) ( $row['subject_id'] ?? 0 ) !== (int) ( $sources['subjectId'] ?? 0 )
			|| (string) ( $row['subject_type'] ?? '' ) !== (string) ( $sources['subjectType'] ?? '' )
		) {
			return false;
		}
		$target = Workflow_Item_Identity::normalize_target_snapshot( $run_item['target'] ?? null );
		$stored_target = $this->decode_target( $row['target_snapshot'] ?? '' );
		if ( is_wp_error( $target ) || ! is_array( $stored_target ) || $target !== $stored_target ) {
			return false;
		}
		$fingerprint = ( new Workflow_Draft_Normalizer() )->fingerprint( $command );
		$stored      = strtolower( trim( (string) ( $row['request_fingerprint'] ?? '' ) ) );
		return is_string( $fingerprint )
			&& 1 === preg_match( '/^[a-f0-9]{64}$/', $stored )
			&& hash_equals( $stored, $fingerprint );
	}

	/**
	 * Link accepted jobs to one current finalizer run without reconstructing a start command.
	 *
	 * @return array{finalizerRunId: string, requestId: string}|\WP_Error
	 */
	private function prepare_accepted_bulk_recovery( array $row, array $job_ids ) {
		$bulk_run_id   = absint( $row['run_id'] ?? 0 );
		$bulk_run_uuid = strtolower( trim( (string) ( $row['run_uuid'] ?? '' ) ) );
		$run_item_id   = strtolower( trim( (string) ( $row['run_item_uuid'] ?? '' ) ) );
		$user_id       = absint( $row['user_id'] ?? 0 );
		$workflow      = sanitize_key( (string) ( $row['workflow'] ?? '' ) );
		$source_type   = sanitize_key( (string) ( $row['subject_type'] ?? '' ) );
		$subject_id    = absint( $row['subject_id'] ?? 0 );
		if (
			$bulk_run_id <= 0
			|| ! wp_is_uuid( $bulk_run_uuid )
			|| ! wp_is_uuid( $run_item_id )
			|| $user_id <= 0
			|| ! in_array( $workflow, self::WORKFLOWS, true )
			|| ! in_array( $source_type, self::SOURCES, true )
			|| $subject_id <= 0
			|| empty( $job_ids )
		) {
			return new \WP_Error( 'accepted_recovery_identity_unverifiable' );
		}
		$request_id = strtolower( trim( (string) ( $row['request_id'] ?? '' ) ) );
		if ( ! wp_is_uuid( $request_id ) ) {
			$request_id = Workflow_Item_Identity::deterministic_uuid( 'fotogenic-ai-accepted-request', $bulk_run_uuid, $run_item_id );
		}
		$finalizer_run_id = trim( (string) ( $row['run_id_single'] ?? '' ) );
		if ( '' === $finalizer_run_id ) {
			$finalizer_run_id = Workflow_Item_Identity::deterministic_uuid( 'fotogenic-ai-accepted-finalizer', $bulk_run_uuid, $run_item_id );
		}
		$logger = Logger::instance();
		$run    = $logger->get_run_row( $finalizer_run_id );
			if ( empty( $run ) ) {
			$target = $this->decode_target( $row['target_snapshot'] ?? '' );
			$summary = [
				'job_id'         => (string) $job_ids[0],
				'run_target'     => $source_type,
				'subject_id'     => $subject_id,
				'workflow'       => $workflow,
				'request_id'     => $request_id,
				'user_id'        => $user_id,
				'started_at'     => (string) ( $row['generation_started_at'] ?? $row['started_at'] ?? gmdate( 'Y-m-d H:i:s' ) ),
				'final_status'   => 'processing',
				'bulk_run_uuid'  => $bulk_run_uuid,
				'bulk_item_id'   => absint( $row['id'] ?? 0 ),
				'run_item_uuid'  => $run_item_id,
				'item_ordinal'   => max( 1, (int) ( $row['item_ordinal'] ?? 1 ) ),
			];
			if ( is_array( $target ) ) {
				$summary['target_snapshot'] = $target;
			}
			$group_id = strtolower( trim( (string) ( $row['campaign_group_uuid'] ?? '' ) ) );
			if ( wp_is_uuid( $group_id ) ) {
				$summary['campaign_group_uuid'] = $group_id;
			}
			$logger->create_run( $finalizer_run_id, $summary );
			$logger->update_run(
				$finalizer_run_id,
				[
					'job_id'     => (string) $job_ids[0],
					'job_ids'    => wp_json_encode( $job_ids ),
					'request_id' => $request_id,
				]
			);
			$run = $logger->get_run_row( $finalizer_run_id );
		}
		if (
			empty( $run )
			|| $user_id !== absint( $run['user_id'] ?? 0 )
			|| $workflow !== sanitize_key( (string) ( $run['workflow'] ?? '' ) )
			|| $source_type !== sanitize_key( (string) ( $run['run_target'] ?? '' ) )
			|| $subject_id !== absint( $run['subject_id'] ?? 0 )
		) {
			return new \WP_Error( 'accepted_recovery_run_conflict' );
		}
		$stored_jobs = $this->run_job_ids( $run );
		if ( ! empty( $stored_jobs ) && ! $this->same_string_set( $job_ids, $stored_jobs ) ) {
			return new \WP_Error( 'accepted_recovery_job_conflict' );
		}
		$logger->update_run(
			$finalizer_run_id,
			[
				'job_id'    => (string) $job_ids[0],
				'job_ids'   => wp_json_encode( $job_ids ),
				'request_id'=> $request_id,
			]
		);
		if ( ! Background_Scheduler::instance()->schedule_finalize( $finalizer_run_id, $user_id, 5 ) ) {
			return new \WP_Error( 'accepted_recovery_schedule_failed' );
		}

		return [
			'finalizerRunId' => $finalizer_run_id,
			'requestId'      => $request_id,
		];
	}

	/** @param mixed $value Candidate value. */
	private function decode_meta( $value ): array {
		if ( is_string( $value ) ) {
			$value = json_decode( $value, true );
		}
		return is_array( $value ) && ! fotogenic_ai_array_is_list( $value ) ? $value : [];
	}

	/** @return array|\WP_Error */
	private function migrate_runs_and_attachments() {
		global $wpdb;
		$runs_table = $wpdb->prefix . 'fotogenic_ai_runs';
		$cursor     = 0;
		$counts     = [
			'runs'                    => 0,
			'request_ids_added'       => 0,
			'provenance_backfilled'   => 0,
			'provenance_unverifiable' => 0,
			'finalizers_scheduled'    => 0,
		];
		do {
			$rows = $wpdb->get_results(
				$wpdb->prepare( "SELECT * FROM {$runs_table} WHERE id > %d ORDER BY id ASC LIMIT %d", $cursor, self::BATCH_SIZE ),
				ARRAY_A
			);
			if ( ! is_array( $rows ) ) {
				return new \WP_Error( 'run_cutover_read_failed' );
			}
			foreach ( $rows as $row ) {
				$id     = absint( $row['id'] ?? 0 );
				$cursor = max( $cursor, $id );
				if ( $id <= 0 ) {
					continue;
				}
				++$counts['runs'];
				$changes = [];
				$run_id  = trim( (string) ( $row['run_id'] ?? '' ) );
				if ( '' !== $run_id && '' === trim( (string) ( $row['request_id'] ?? '' ) ) ) {
					$changes['request_id'] = Workflow_Item_Identity::deterministic_uuid(
						'fotogenic-ai-universal-request',
						$run_id,
						(string) absint( $row['user_id'] ?? 0 )
					);
					++$counts['request_ids_added'];
				}
				if ( in_array( sanitize_key( (string) ( $row['target_type'] ?? '' ) ), [ '', 'legacy' ], true ) ) {
					$changes['target_type'] = 'unknown';
				}
				if ( ! empty( $changes ) && false === $wpdb->update( $runs_table, $changes, [ 'id' => $id ] ) ) {
					return new \WP_Error( 'run_cutover_write_failed' );
				}
				$effective = array_merge( $row, $changes );
				if ( $this->run_has_jobs( $effective ) && $this->run_is_active( $effective ) ) {
					$old_args = [
						'run_id'  => $run_id,
						'user_id' => absint( $row['user_id'] ?? 0 ),
					];
					wp_clear_scheduled_hook( Background_Scheduler::HOOK_FINALIZE, [ $old_args ] );
					if ( ! Background_Scheduler::instance()->schedule_finalize( $run_id, absint( $row['user_id'] ?? 0 ), 5 ) ) {
						return new \WP_Error( 'run_finalizer_schedule_failed' );
					}
					++$counts['finalizers_scheduled'];
				}
			}
		} while ( count( $rows ) === self::BATCH_SIZE );

		$attachment_counts = $this->backfill_attachment_provenance();
		if ( is_wp_error( $attachment_counts ) ) {
			return $attachment_counts;
		}
		$counts['provenance_backfilled']   = $attachment_counts['backfilled'];
		$counts['provenance_unverifiable'] = $attachment_counts['unverifiable'];

		return $counts;
	}

	/** @return array{backfilled: int, unverifiable: int}|\WP_Error */
	private function backfill_attachment_provenance() {
		global $wpdb;
		$cursor = 0;
		$counts = [
			'backfilled'   => 0,
			'unverifiable' => 0,
		];
		do {
			$ids = $wpdb->get_col(
				$wpdb->prepare(
					"SELECT pm.post_id FROM {$wpdb->postmeta} pm
					INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id AND p.post_type = 'attachment'
					WHERE pm.meta_key = %s AND pm.meta_value = %s AND pm.post_id > %d
					ORDER BY pm.post_id ASC LIMIT %d",
					'_fotogenic_ai_generated',
					'1',
					$cursor,
					self::BATCH_SIZE
				)
			);
			if ( ! is_array( $ids ) ) {
				return new \WP_Error( 'attachment_cutover_read_failed' );
			}
			foreach ( $ids as $raw_id ) {
				$attachment_id = absint( $raw_id );
				$cursor        = max( $cursor, $attachment_id );
				if ( $attachment_id <= 0 || '' !== (string) get_post_meta( $attachment_id, '_fotogenic_ai_run_id', true ) ) {
					continue;
				}
				$job_id  = trim( (string) get_post_meta( $attachment_id, '_fotogenic_ai_job_id', true ) );
				$user_id = absint( get_post_meta( $attachment_id, '_fotogenic_ai_user_id', true ) );
				$run     = $this->find_unique_run_for_job( $job_id, $user_id );
				if ( null === $run || ! $this->attachment_context_is_complete( $run ) || ! $this->existing_attachment_meta_matches( $attachment_id, $run ) ) {
					++$counts['unverifiable'];
					continue;
				}
				$this->write_attachment_provenance( $attachment_id, $run );
				++$counts['backfilled'];
			}
		} while ( count( $ids ) === self::BATCH_SIZE );

		return $counts;
	}

	private function find_unique_run_for_job( string $job_id, int $user_id ): ?array {
		if ( '' === $job_id || $user_id <= 0 ) {
			return null;
		}
		global $wpdb;
		$table = $wpdb->prefix . 'fotogenic_ai_runs';
		$rows  = $wpdb->get_results(
			$wpdb->prepare(
				"SELECT * FROM {$table} WHERE user_id = %d AND (job_id = %s OR job_ids LIKE %s) ORDER BY id ASC LIMIT %d",
				$user_id,
				$job_id,
				'%' . $wpdb->esc_like( $job_id ) . '%',
				20
			),
			ARRAY_A
		);
		if ( ! is_array( $rows ) ) {
			return null;
		}
		$matches = array_values(
			array_filter(
				$rows,
				fn( array $row ): bool => in_array( $job_id, $this->run_job_ids( $row ), true )
			)
		);

		return 1 === count( $matches ) ? $matches[0] : null;
	}

	private function attachment_context_is_complete( array $run ): bool {
		return '' !== trim( (string) ( $run['run_id'] ?? '' ) )
			&& absint( $run['user_id'] ?? 0 ) > 0
			&& in_array( sanitize_key( (string) ( $run['workflow'] ?? '' ) ), self::WORKFLOWS, true )
			&& in_array( sanitize_key( (string) ( $run['run_target'] ?? '' ) ), self::SOURCES, true )
			&& absint( $run['subject_id'] ?? 0 ) > 0
			&& '' !== trim( (string) ( $run['request_id'] ?? '' ) );
	}

	private function existing_attachment_meta_matches( int $attachment_id, array $run ): bool {
		$expected = [
			'_fotogenic_ai_user_id'    => (string) absint( $run['user_id'] ?? 0 ),
			'_fotogenic_ai_workflow'   => sanitize_key( (string) ( $run['workflow'] ?? '' ) ),
			'_fotogenic_ai_run_id'     => (string) ( $run['run_id'] ?? '' ),
			'_fotogenic_ai_request_id' => (string) ( $run['request_id'] ?? '' ),
			'_fotogenic_ai_run_target' => sanitize_key( (string) ( $run['run_target'] ?? '' ) ),
			'_fotogenic_ai_subject_id' => (string) absint( $run['subject_id'] ?? 0 ),
		];
		foreach ( $expected as $key => $value ) {
			$existing = (string) get_post_meta( $attachment_id, $key, true );
			if ( '' !== $existing && ! hash_equals( $value, $existing ) ) {
				return false;
			}
		}

		return true;
	}

	private function write_attachment_provenance( int $attachment_id, array $run ): void {
		$meta = [
			'_fotogenic_ai_workflow'   => sanitize_key( (string) $run['workflow'] ),
			'_fotogenic_ai_run_id'     => substr( sanitize_text_field( (string) $run['run_id'] ), 0, 64 ),
			'_fotogenic_ai_request_id' => substr( sanitize_text_field( (string) $run['request_id'] ), 0, 64 ),
			'_fotogenic_ai_run_target' => sanitize_key( (string) $run['run_target'] ),
			'_fotogenic_ai_subject_id' => absint( $run['subject_id'] ?? 0 ),
		];
		foreach ( [ 'run_item_uuid', 'campaign_group_uuid', 'item_ordinal' ] as $field ) {
			if ( '' !== trim( (string) ( $run[ $field ] ?? '' ) ) ) {
				$meta[ '_fotogenic_ai_' . $field ] = $run[ $field ];
			}
		}
		foreach ( $meta as $key => $value ) {
			if ( '' === (string) get_post_meta( $attachment_id, $key, true ) ) {
				update_post_meta( $attachment_id, $key, $value );
			}
		}
	}

	/** @return array{current: int, incompatible: int}|\WP_Error */
	private function migrate_presets() {
		global $wpdb;
		$table = $wpdb->prefix . 'fotogenic_ai_presets';
		foreach ( [ 'id', 'payload_json', 'format_status', 'workflow', 'source_type', 'scope' ] as $column ) {
			if ( $column !== (string) $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", $column ) ) ) {
				return new \WP_Error( 'preset_cutover_schema_unavailable' );
			}
		}
		$has_old_payload = 'data' === (string) $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$table} LIKE %s", 'data' ) );
		$payload_select  = $has_old_payload ? ', data' : '';
		$rows = $wpdb->get_results( "SELECT id, payload_json{$payload_select} FROM {$table} ORDER BY id ASC", ARRAY_A );
		if ( ! is_array( $rows ) ) {
			return new \WP_Error( 'preset_cutover_read_failed' );
		}
		$counts = [ 'current' => 0, 'incompatible' => 0 ];
		foreach ( $rows as $row ) {
			$raw = '' !== (string) ( $row['payload_json'] ?? '' ) ? (string) $row['payload_json'] : (string) ( $row['data'] ?? '' );
			$decoded = json_decode( $raw, false, 64 );
			$normalized = $decoded instanceof \stdClass
				? ( new Workflow_Draft_Normalizer() )->normalize_recipe_snapshot( $decoded )
				: new \WP_Error( 'fotogenic_ai_incompatible_preset' );
			$data = [
				'payload_json'  => $raw,
				'format_status' => 'incompatible',
				'workflow'      => null,
				'source_type'   => null,
				'scope'         => null,
			];
			if ( ! is_wp_error( $normalized ) ) {
				$data['format_status'] = 'current';
				$data['workflow']      = (string) $normalized['workflow'];
				$data['source_type']   = (string) $normalized['sourceType'];
				$data['scope']         = (string) $normalized['scope'];
			}
			if ( false === $wpdb->update( $table, $data, [ 'id' => absint( $row['id'] ?? 0 ) ] ) ) {
				return new \WP_Error( 'preset_cutover_write_failed' );
			}
			++$counts[ $data['format_status'] ];
		}
		$unclassified = $wpdb->get_var( "SELECT COUNT(*) FROM {$table} WHERE format_status NOT IN ('current', 'incompatible')" );
		if ( null === $unclassified || 0 !== (int) $unclassified || ( $has_old_payload && ! $this->drop_column_if_present( $table, 'data' ) ) ) {
			return new \WP_Error( 'preset_cutover_verification_failed' );
		}

		return $counts;
	}

	private function verify_current_state(): bool {
		if ( ! $this->component_versions_supported()
			|| ! Logger::instance()->ensure_schema()
			|| ! Presets::instance()->ensure_schema()
			|| ! Bulk_Repo::instance()->ensure_tables()
			|| ! Store_Queue_Repo::instance()->ensure_tables() ) {
			return false;
		}
		global $wpdb;
		$presets_table = $wpdb->prefix . 'fotogenic_ai_presets';
		$unclassified = $wpdb->get_var( "SELECT COUNT(*) FROM {$presets_table} WHERE format_status NOT IN ('current', 'incompatible')" );
		$old_payload_column = $wpdb->get_var( $wpdb->prepare( "SHOW COLUMNS FROM {$presets_table} LIKE %s", 'data' ) );
		if ( null === $unclassified || 0 !== (int) $unclassified || 'data' === (string) $old_payload_column ) {
			return false;
		}
		$runs_table  = $wpdb->prefix . 'fotogenic_ai_bulk_runs';
		$items_table = $wpdb->prefix . 'fotogenic_ai_bulk_run_items';
		$rows = $wpdb->get_results(
			"SELECT i.*, r.run_uuid, r.user_id, r.workflow FROM {$items_table} i INNER JOIN {$runs_table} r ON r.id = i.run_id
			WHERE i.status IN ('pending', 'starting', 'generating')",
			ARRAY_A
		);
		if ( ! is_array( $rows ) ) {
			return false;
		}
		foreach ( $rows as $row ) {
			$status = (string) ( $row['status'] ?? '' );
			if ( ! in_array( $status, [ 'pending', 'starting', 'generating' ], true ) || ! $this->bulk_command_is_current( $row ) ) {
				return false;
			}
			if ( 'generating' === $status && empty( $this->run_job_ids( $row ) ) ) {
				return false;
			}
		}
		$unsafe_quarantine = $wpdb->get_var(
			"SELECT COUNT(*) FROM {$items_table} i INNER JOIN {$runs_table} r ON r.id = i.run_id
			WHERE i.error_code IN ('MIGRATION_COMMAND_UNVERIFIABLE', 'MIGRATION_ACCEPTED_RECOVERY_CONFLICT', 'MIGRATION_SIDE_EFFECT_UNVERIFIABLE')
			AND r.status IN ('created', 'admitting', 'running', 'pausing')"
		);

		return null !== $unsafe_quarantine && 0 === (int) $unsafe_quarantine;
	}

	private function same_string_set( array $left, array $right ): bool {
		sort( $left, SORT_STRING );
		sort( $right, SORT_STRING );
		return $left === $right;
	}

	/** @param mixed $value Candidate value. */
	private function decode_target( $value ): ?array {
		if ( is_string( $value ) ) {
			$value = json_decode( $value, true );
		}
		$target = Workflow_Item_Identity::normalize_target_snapshot( $value );
		return is_wp_error( $target ) || ! in_array( (string) ( $target['targetType'] ?? '' ), [ 'product', 'variation', 'media', 'post', 'page' ], true )
			? null
			: $target;
	}

	/** @param mixed $value Candidate value. */
	private function decode_job_ids( $value ): array {
		if ( is_string( $value ) ) {
			$decoded = json_decode( $value, true );
			$value   = is_array( $decoded ) ? $decoded : [ $value ];
		}
		if ( ! is_array( $value ) ) {
			return [];
		}

		return array_values( array_unique( array_filter( array_map( static fn( $job ): string => trim( (string) $job ), $value ) ) ) );
	}

	private function run_job_ids( array $row ): array {
		$ids = $this->decode_job_ids( $row['job_ids'] ?? [] );
		$single = trim( (string) ( $row['job_id'] ?? '' ) );
		if ( '' !== $single ) {
			$ids[] = $single;
		}
		return array_values( array_unique( $ids ) );
	}

	private function run_has_jobs( array $row ): bool {
		return ! empty( $this->run_job_ids( $row ) );
	}

	private function run_is_active( array $row ): bool {
		return in_array( sanitize_key( (string) ( $row['final_status'] ?? '' ) ), [ '', 'started', 'starting', 'processing' ], true );
	}

	/**
	 * Convert only stored pre-v2 base paths; runtime settings accept current values.
	 */
	private function normalize_base_path_for_cutover( string $path ): string {
		$trimmed = trim( trim( $path ), '/' );
		if ( fotogenic_ai_str_starts_with( $trimmed, 'v1' ) ) {
			$trimmed = 'v2' . substr( $trimmed, 2 );
		}

		return fotogenic_ai_str_starts_with( $trimmed, 'v2' ) ? '/' . $trimmed : '/v2';
	}

	/**
	 * Convert stored endpoint names/paths once before permanent readers are removed.
	 */
	private function normalize_endpoints_for_cutover( array $endpoints ): array {
		$path_map = [
			'configuration_generator' => [ '/configuration-generator', '/generateConfiguration' ],
			'images_generator'        => [ '/images-generator', '/generateImages' ],
			'description_generator'   => [ '/description-generator', '/generateDescription' ],
		];
		foreach ( $path_map as $key => [ $old, $current ] ) {
			if ( (string) ( $endpoints[ $key ] ?? '' ) === $old ) {
				$endpoints[ $key ] = $current;
			}
		}
		unset( $endpoints['report_problem'] );
		if ( isset( $endpoints['jobs'] ) && ! isset( $endpoints['job'] ) ) {
			$endpoints['job'] = $endpoints['jobs'];
		}
		unset( $endpoints['jobs'] );
		if ( ! isset( $endpoints['jobs_batch'] ) ) {
			$endpoints['jobs_batch'] = '/jobs';
		}
		if ( isset( $endpoints['credits_balance'] ) ) {
			$endpoints['credits_balance'] = rtrim( str_replace( '{clientUUID}', '', (string) $endpoints['credits_balance'] ), '/' );
			if ( '' === $endpoints['credits_balance'] ) {
				$endpoints['credits_balance'] = '/credits/balance';
			}
		}
		foreach ( $endpoints as $key => $value ) {
			$normalized = '/' . ltrim( trim( (string) $value ), '/' );
			if ( fotogenic_ai_str_starts_with( $normalized, '/v1/' ) || fotogenic_ai_str_starts_with( $normalized, '/v2/' ) ) {
				$normalized = '/' . ltrim( substr( $normalized, 3 ), '/' );
			}
			$endpoints[ $key ] = $normalized;
		}
		$defaults = Api_Endpoints::get_default_endpoints();
		$current  = [];
		foreach ( $defaults as $key => $default ) {
			$path = trim( (string) ( $endpoints[ $key ] ?? $default ) );
			$path = '' === $path ? '' : '/' . ltrim( $path, '/' );
			$current[ $key ] = '' === $path || preg_match( '#^/(?:https?:|v\d+/)#i', $path ) ? $default : $path;
		}

		return $current;
	}

	private function acquire_lock(): string {
		global $wpdb;
		$token = wp_generate_uuid4();
		$value = [
			'token'      => $token,
			'expires_at' => time() + self::LOCK_TTL,
		];
		for ( $attempt = 0; $attempt < 2; $attempt++ ) {
			$inserted = $wpdb->query(
				$wpdb->prepare(
					"INSERT IGNORE INTO {$wpdb->options} (option_name, option_value, autoload) VALUES (%s, %s, %s)",
					self::LOCK_OPTION,
					maybe_serialize( $value ),
					function_exists( 'wp_autoload_values_to_autoload' ) ? 'off' : 'no'
				)
			);
			$this->clear_option_cache( self::LOCK_OPTION );
			if ( 1 === (int) $inserted ) {
				return $token;
			}
			$existing_serialized = $wpdb->get_var(
				$wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", self::LOCK_OPTION )
			);
			$existing = null === $existing_serialized ? null : maybe_unserialize( $existing_serialized );
			if ( ! is_array( $existing ) || (int) ( $existing['expires_at'] ?? 0 ) > time() ) {
				return '';
			}
			$wpdb->query(
				$wpdb->prepare(
					"DELETE FROM {$wpdb->options} WHERE option_name = %s AND BINARY option_value = BINARY %s",
					self::LOCK_OPTION,
					(string) $existing_serialized
				)
			);
			$this->clear_option_cache( self::LOCK_OPTION );
		}

		return '';
	}

	private function release_lock( string $token ): void {
		if ( '' === $token ) {
			return;
		}
		global $wpdb;
		$serialized = $wpdb->get_var(
			$wpdb->prepare( "SELECT option_value FROM {$wpdb->options} WHERE option_name = %s LIMIT 1", self::LOCK_OPTION )
		);
		$value = null === $serialized ? null : maybe_unserialize( $serialized );
		if ( ! is_array( $value ) || ! hash_equals( $token, (string) ( $value['token'] ?? '' ) ) ) {
			return;
		}
		$wpdb->query(
			$wpdb->prepare(
				"DELETE FROM {$wpdb->options} WHERE option_name = %s AND BINARY option_value = BINARY %s",
				self::LOCK_OPTION,
				(string) $serialized
			)
		);
		$this->clear_option_cache( self::LOCK_OPTION );
	}

	private function write_journal( string $status, string $step, int $started_at, array $counts, string $error_code = '' ): void {
		update_option(
			self::JOURNAL_OPTION,
			[
				'version'    => self::SCHEMA_VERSION,
				'status'     => $status,
				'step'       => $step,
				'started_at' => $started_at,
				'updated_at' => time(),
				'counts'     => $counts,
				'error_code' => $error_code,
			],
			false
		);
	}

	private function fail( string $error_code, string $step, int $started_at = 0, array $counts = [] ): bool {
		$this->ready      = false;
		$this->error_code = sanitize_key( $error_code );
		$this->write_journal( 'failed', $step, $started_at > 0 ? $started_at : time(), $counts, $this->error_code );
		return false;
	}

	private function clear_option_cache( string $option_name ): void {
		wp_cache_delete( $option_name, 'options' );
		wp_cache_delete( 'alloptions', 'options' );
		wp_cache_delete( 'notoptions', 'options' );
	}
}
