<?php
/**
 * V2 per-post guided schema instance storage.
 *
 * Stores versioned documents in `_seovault_schema_instances_v2`, independent of
 * block content. Client-supplied readiness/status is never trusted.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

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

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

/**
 * Versioned meta read/write/delete API for guided instances.
 */
class SchemaInstanceStorage {

	/**
	 * Post meta key.
	 */
	const META_KEY = '_seovault_schema_instances_v2';

	/**
	 * Document schema version.
	 */
	const DOCUMENT_VERSION = 2;

	/**
	 * Soft default max instances per post (filterable).
	 */
	const DEFAULT_MAX_INSTANCES = 20;

	/**
	 * Hard max instances per post.
	 */
	const HARD_MAX_INSTANCES = 50;

	/**
	 * Soft default max document bytes (filterable).
	 */
	const DEFAULT_MAX_DOCUMENT_BYTES = 196608;

	/**
	 * Hard max document bytes.
	 */
	const HARD_MAX_DOCUMENT_BYTES = 262144;

	/**
	 * Allowed instance source kinds.
	 *
	 * @var string[]
	 */
	const ALLOWED_SOURCES = array(
		'manual',
		'migration',
		'api',
		'web',
	);

	/**
	 * Whether init has run.
	 *
	 * @var bool
	 */
	private static $initialized = false;

	/**
	 * Register post meta (not exposed via REST; Phase 23 adds CRUD endpoints).
	 *
	 * @return void
	 */
	public static function init() {
		if ( self::$initialized ) {
			return;
		}
		self::$initialized = true;

		add_action( 'init', array( __CLASS__, 'register_meta' ), 20 );
	}

	/**
	 * Register the instances meta key with auth (no show_in_rest).
	 *
	 * @return void
	 */
	public static function register_meta() {
		$args = array(
			'type'              => 'object',
			'description'       => 'SEOvault Schema Architecture V2 guided instances document.',
			'single'            => true,
			'default'           => self::empty_document(),
			'show_in_rest'      => false,
			'auth_callback'     => array( __CLASS__, 'meta_auth_callback' ),
			'revisions_enabled' => false,
		);

		// Empty object type = all public post types that support custom fields.
		register_post_meta( '', self::META_KEY, $args );
	}

	/**
	 * Auth callback for direct meta writes.
	 *
	 * @param bool   $allowed  Prior.
	 * @param string $meta_key Meta key.
	 * @param int    $post_id  Post ID.
	 * @return bool
	 */
	public static function meta_auth_callback( $allowed, $meta_key, $post_id ) {
		unset( $allowed, $meta_key );
		$post_id = (int) $post_id;
		if ( $post_id <= 0 ) {
			return false;
		}
		return current_user_can( 'edit_post', $post_id );
	}

	/**
	 * Empty versioned document.
	 *
	 * @return array{version:int,instances:array}
	 */
	public static function empty_document() {
		return array(
			'version'   => self::DOCUMENT_VERSION,
			'instances' => array(),
		);
	}

	/**
	 * Migration-compatible document reader.
	 *
	 * Accepts array meta, JSON-string meta, and legacy migration shapes.
	 * Does not trust stored status; callers that need readiness must recompute.
	 *
	 * @param int $post_id Post ID.
	 * @return array{version:int,instances:array<int,array<string,mixed>>}
	 */
	public static function read_document( $post_id ) {
		$post_id = (int) $post_id;
		if ( $post_id <= 0 ) {
			return self::empty_document();
		}

		$raw = get_post_meta( $post_id, self::META_KEY, true );
		return self::normalize_document( $raw );
	}

	/**
	 * Normalize a raw meta value into a versioned document.
	 *
	 * @param mixed $raw Raw meta.
	 * @return array{version:int,instances:array<int,array<string,mixed>>}
	 */
	public static function normalize_document( $raw ) {
		if ( is_string( $raw ) && '' !== $raw ) {
			$decoded = json_decode( $raw, true );
			$raw     = is_array( $decoded ) ? $decoded : array();
		}
		if ( ! is_array( $raw ) ) {
			return self::empty_document();
		}

		$instances_raw = array();
		if ( isset( $raw['instances'] ) && is_array( $raw['instances'] ) ) {
			$instances_raw = $raw['instances'];
		} elseif ( self::looks_like_instance_list( $raw ) ) {
			// Compatibility: bare list of instances.
			$instances_raw = $raw;
		}

		$instances = array();
		foreach ( $instances_raw as $instance ) {
			$normalized = self::normalize_instance_shape( $instance );
			if ( null !== $normalized ) {
				$instances[] = $normalized;
			}
		}

		return array(
			'version'   => self::DOCUMENT_VERSION,
			'instances' => array_values( $instances ),
		);
	}

	/**
	 * Replace the full document after sanitization.
	 *
	 * @param int   $post_id  Post ID.
	 * @param mixed $document Raw document or instances list.
	 * @param array $context  Options: skip_cap_check, actor_user_id.
	 * @return array{ok:bool,document:array,errors:string[],written:bool}
	 */
	public static function write_document( $post_id, $document, $context = array() ) {
		$post_id = (int) $post_id;
		$gate    = self::assert_writable_post( $post_id, $context );
		if ( ! $gate['ok'] ) {
			return array(
				'ok'       => false,
				'document' => self::empty_document(),
				'errors'   => $gate['errors'],
				'written'  => false,
			);
		}

		$normalized = self::normalize_document( $document );
		$sanitized  = self::sanitize_document_for_storage( $post_id, $normalized, $context );
		if ( ! $sanitized['ok'] ) {
			return array(
				'ok'       => false,
				'document' => self::empty_document(),
				'errors'   => $sanitized['errors'],
				'written'  => false,
			);
		}

		$bytes = strlen( (string) wp_json_encode( $sanitized['document'] ) );
		$max   = self::document_byte_limit();
		if ( $bytes > $max ) {
			return array(
				'ok'       => false,
				'document' => self::empty_document(),
				'errors'   => array( 'document_too_large' ),
				'written'  => false,
			);
		}

		update_post_meta( $post_id, self::META_KEY, $sanitized['document'] );
		SchemaValidationCache::delete_local( $post_id );

		return array(
			'ok'       => true,
			'document' => $sanitized['document'],
			'errors'   => array(),
			'written'  => true,
		);
	}

	/**
	 * Delete the instances document for a post.
	 *
	 * @param int   $post_id Post ID.
	 * @param array $context Options: skip_cap_check.
	 * @return array{ok:bool,errors:string[],deleted:bool}
	 */
	public static function delete_document( $post_id, $context = array() ) {
		$post_id = (int) $post_id;
		$gate    = self::assert_writable_post( $post_id, $context );
		if ( ! $gate['ok'] ) {
			return array(
				'ok'      => false,
				'errors'  => $gate['errors'],
				'deleted' => false,
			);
		}

		delete_post_meta( $post_id, self::META_KEY );
		SchemaValidationCache::delete_local( $post_id );

		return array(
			'ok'      => true,
			'errors'  => array(),
			'deleted' => true,
		);
	}

	/**
	 * Create or update one instance inside the document.
	 *
	 * New instances always receive a server-generated UUID. Client IDs are
	 * accepted only when they match an existing instance (update) or pass
	 * strict UUID validation and are unused (rare import path via context
	 * `allow_client_uuid` + valid UUID).
	 *
	 * @param int   $post_id  Post ID.
	 * @param array $payload  Instance payload (type, fields, source, id?).
	 * @param array $context  Write context.
	 * @return array{ok:bool,instance:?array,document:array,errors:string[]}
	 */
	public static function upsert_instance( $post_id, array $payload, $context = array() ) {
		$post_id = (int) $post_id;
		$gate    = self::assert_writable_post( $post_id, $context );
		if ( ! $gate['ok'] ) {
			return array(
				'ok'       => false,
				'instance' => null,
				'document' => self::empty_document(),
				'errors'   => $gate['errors'],
			);
		}

		$doc       = self::read_document( $post_id );
		$existing  = null;
		$existing_index = null;
		$client_id = isset( $payload['id'] ) ? (string) $payload['id'] : '';

		if ( '' !== $client_id ) {
			foreach ( $doc['instances'] as $index => $instance ) {
				if ( (string) ( $instance['id'] ?? '' ) === $client_id ) {
					$existing       = $instance;
					$existing_index = $index;
					break;
				}
			}
			if ( null === $existing ) {
				if ( empty( $context['allow_client_uuid'] ) || ! self::is_valid_uuid( $client_id ) ) {
					return array(
						'ok'       => false,
						'instance' => null,
						'document' => $doc,
						'errors'   => array( 'invalid_or_unknown_instance_id' ),
					);
				}
				// Prevent duplicate UUIDs across the document (already checked by loop).
			}
		}

		$type = (string) ( $payload['type'] ?? ( $existing['type'] ?? '' ) );
		if ( '' === $type || ! SchemaInstanceFieldSanitizer::is_guided_or_manual_product_type( $type ) ) {
			return array(
				'ok'       => false,
				'instance' => null,
				'document' => $doc,
				'errors'   => array( 'unknown_type' ),
			);
		}

		$field_result = SchemaInstanceFieldSanitizer::sanitize( $type, $payload['fields'] ?? array(), $context );
		if ( ! $field_result['ok'] ) {
			return array(
				'ok'       => false,
				'instance' => null,
				'document' => $doc,
				'errors'   => $field_result['errors'],
			);
		}

		$max_instances = self::max_instances();
		if ( null === $existing && count( $doc['instances'] ) >= $max_instances ) {
			return array(
				'ok'       => false,
				'instance' => null,
				'document' => $doc,
				'errors'   => array( 'too_many_instances' ),
			);
		}

		$now    = time();
		$source = self::sanitize_source( $payload['source'] ?? ( $existing['source'] ?? 'manual' ) );
		$id     = null !== $existing
			? (string) $existing['id']
			: ( ( '' !== $client_id && ! empty( $context['allow_client_uuid'] ) && self::is_valid_uuid( $client_id ) )
				? strtolower( $client_id )
				: self::generate_instance_id() );

		$instance = array(
			'id'         => $id,
			'type'       => $type,
			'source'     => $source,
			'fields'     => $field_result['fields'],
			'created_at' => null !== $existing ? (int) ( $existing['created_at'] ?? $now ) : $now,
			'updated_at' => $now,
		);

		if ( isset( $payload['source_meta'] ) && is_array( $payload['source_meta'] ) ) {
			$instance['source_meta'] = self::sanitize_source_meta( $payload['source_meta'] );
		} elseif ( isset( $existing['source_meta'] ) && is_array( $existing['source_meta'] ) ) {
			$instance['source_meta'] = self::sanitize_source_meta( $existing['source_meta'] );
		}

		// Preserve migration bookkeeping; never accept client readiness.
		if ( isset( $payload['migration'] ) && is_array( $payload['migration'] ) ) {
			$instance['migration'] = self::sanitize_migration_meta( $payload['migration'] );
		} elseif ( isset( $existing['migration'] ) && is_array( $existing['migration'] ) ) {
			$instance['migration'] = self::sanitize_migration_meta( $existing['migration'] );
		}

		$instance = self::apply_derived_readiness( $instance );

		if ( null !== $existing_index ) {
			$doc['instances'][ $existing_index ] = $instance;
		} else {
			$doc['instances'][] = $instance;
		}
		$doc['version'] = self::DOCUMENT_VERSION;

		$write = self::write_document( $post_id, $doc, array_merge( $context, array( 'skip_cap_check' => true ) ) );
		if ( ! $write['ok'] ) {
			return array(
				'ok'       => false,
				'instance' => null,
				'document' => $doc,
				'errors'   => $write['errors'],
			);
		}

		return array(
			'ok'       => true,
			'instance' => $instance,
			'document' => $write['document'],
			'errors'   => array(),
		);
	}

	/**
	 * Delete one instance by id.
	 *
	 * @param int    $post_id     Post ID.
	 * @param string $instance_id Instance id.
	 * @param array  $context     Context.
	 * @return array{ok:bool,document:array,errors:string[]}
	 */
	public static function delete_instance( $post_id, $instance_id, $context = array() ) {
		$post_id     = (int) $post_id;
		$instance_id = (string) $instance_id;
		$gate        = self::assert_writable_post( $post_id, $context );
		if ( ! $gate['ok'] ) {
			return array(
				'ok'       => false,
				'document' => self::empty_document(),
				'errors'   => $gate['errors'],
			);
		}

		if ( '' === $instance_id ) {
			return array(
				'ok'       => false,
				'document' => self::read_document( $post_id ),
				'errors'   => array( 'invalid_or_unknown_instance_id' ),
			);
		}

		$doc     = self::read_document( $post_id );
		$kept    = array();
		$found   = false;
		foreach ( $doc['instances'] as $instance ) {
			if ( (string) ( $instance['id'] ?? '' ) === $instance_id ) {
				$found = true;
				continue;
			}
			$kept[] = $instance;
		}

		if ( ! $found ) {
			return array(
				'ok'       => false,
				'document' => $doc,
				'errors'   => array( 'invalid_or_unknown_instance_id' ),
			);
		}

		$doc['instances'] = $kept;
		$write            = self::write_document( $post_id, $doc, array_merge( $context, array( 'skip_cap_check' => true ) ) );
		return array(
			'ok'       => $write['ok'],
			'document' => $write['document'],
			'errors'   => $write['errors'],
		);
	}

	/**
	 * Ready instances as legacy-shaped schema configs for emission.
	 *
	 * Always recomputes readiness; never trusts stored status.
	 *
	 * @param int $post_id Post ID.
	 * @return array<int, array<string,mixed>>
	 */
	public static function get_ready_schema_configs( $post_id ) {
		$doc = self::read_document( $post_id );
		$out = array();
		foreach ( $doc['instances'] as $instance ) {
			if ( ! is_array( $instance ) ) {
				continue;
			}
			$type   = (string) ( $instance['type'] ?? '' );
			$fields = isset( $instance['fields'] ) && is_array( $instance['fields'] ) ? $instance['fields'] : array();
			if ( '' === $type ) {
				continue;
			}
			$ready = SchemaReadinessEvaluator::evaluate(
				$type,
				$fields,
				array(
					'enforce_visibility' => true,
					'source_meta'        => isset( $instance['source_meta'] ) && is_array( $instance['source_meta'] ) ? $instance['source_meta'] : array(),
				)
			);
			if ( ! $ready->is_ready() ) {
				continue;
			}
			$out[] = array(
				'title'   => $type,
				'type'    => $type,
				'enabled' => true,
				'show_on' => array(
					'rules'        => array( 'basic-global' ),
					'specific'     => array(),
					'specificText' => array(),
				),
				'fields'  => $fields,
			);
		}
		return $out;
	}

	/**
	 * Export guided instances for Schema Health setup / editable-entity UI.
	 *
	 * Recomputes readiness; never trusts stored status. Unfinished rows belong
	 * under Unfinished setup; ready rows are editable published entities.
	 *
	 * @param int $post_id Post ID.
	 * @return array{unfinished:array<int,array<string,mixed>>,ready:array<int,array<string,mixed>>}
	 */
	public static function export_editor_setup( $post_id ) {
		$doc         = self::read_document( $post_id );
		$unfinished  = array();
		$ready       = array();

		foreach ( $doc['instances'] as $raw ) {
			if ( ! is_array( $raw ) ) {
				continue;
			}
			$id = (string) ( $raw['id'] ?? '' );
			$type = (string) ( $raw['type'] ?? '' );
			if ( '' === $id || '' === $type ) {
				continue;
			}

			$fields     = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array();
			$source_meta = isset( $raw['source_meta'] ) && is_array( $raw['source_meta'] ) ? $raw['source_meta'] : array();
			$result     = SchemaReadinessEvaluator::evaluate(
				$type,
				$fields,
				array(
					'enforce_visibility' => true,
					'source_meta'        => $source_meta,
				)
			);
			$setup     = array();
			$first_key = '';
			foreach ( $result->get_blockers() as $issue ) {
				$row = $issue->to_array();
				$setup[] = array(
					'code'       => (string) ( $row['code'] ?? '' ),
					'kind'       => (string) ( $row['kind'] ?? 'blocker' ),
					'field_key'  => (string) ( $row['field_key'] ?? '' ),
					'action_key' => (string) ( $row['action_key'] ?? '' ),
					'path'       => (string) ( $row['path'] ?? '' ),
					'type'       => $type,
				);
				if ( '' === $first_key && ! empty( $row['field_key'] ) ) {
					$first_key = (string) $row['field_key'];
				}
			}

			$row = array(
				'instance_id'     => $id,
				'id'              => $id,
				'type'            => $type,
				'source'          => 'post_instance',
				'source_ref'      => 'instance:' . $id,
				'reason'          => $result->is_ready() ? 'ready' : 'draft',
				'status'          => $result->is_ready() ? 'ready' : 'draft',
				'setup'           => $setup,
				'first_field_key' => $first_key,
				'editable'        => true,
				'fields'          => $fields,
				'source_meta'     => $source_meta,
			);

			if ( $result->is_ready() ) {
				$ready[] = $row;
			} else {
				$unfinished[] = $row;
			}
		}

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

	/**
	 * Generate a server-side instance UUID.
	 *
	 * @return string
	 */
	public static function generate_instance_id() {
		if ( function_exists( 'wp_generate_uuid4' ) ) {
			return strtolower( (string) wp_generate_uuid4() );
		}
		// PHP 7.4 fallback (should not hit on WP 5.9+).
		$data = random_bytes( 16 );
		$data[6] = chr( ( ord( $data[6] ) & 0x0f ) | 0x40 );
		$data[8] = chr( ( ord( $data[8] ) & 0x3f ) | 0x80 );
		return strtolower(
			vsprintf(
				'%s%s-%s-%s-%s-%s%s%s',
				str_split( bin2hex( $data ), 4 )
			)
		);
	}

	/**
	 * Whether a string is a UUID (any version, RFC-like).
	 *
	 * @param string $value Candidate.
	 * @return bool
	 */
	public static function is_valid_uuid( $value ) {
		$value = (string) $value;
		return (bool) preg_match(
			'/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i',
			$value
		);
	}

	/**
	 * Whether a stored/migration instance id is acceptable.
	 *
	 * Accepts UUIDs and stable migration ids (`sv2-post-…`).
	 *
	 * @param string $value Candidate.
	 * @return bool
	 */
	public static function is_acceptable_instance_id( $value ) {
		$value = (string) $value;
		if ( self::is_valid_uuid( $value ) ) {
			return true;
		}
		// Migration-stable ids from SchemaPostMigration::stable_instance_id().
		return (bool) preg_match( '/^sv2-post-[a-f0-9]{16,64}$/i', $value );
	}

	/**
	 * Sanitize an inbound document for storage (definitions + readiness).
	 *
	 * @param int   $post_id  Post ID (unused; reserved for future context).
	 * @param array $document Normalized document.
	 * @param array $context  Context.
	 * @return array{ok:bool,document:array,errors:string[]}
	 */
	public static function sanitize_document_for_storage( $post_id, array $document, $context = array() ) {
		unset( $post_id );
		$errors    = array();
		$instances = array();
		$seen_ids  = array();
		$max       = self::max_instances();

		foreach ( $document['instances'] as $raw ) {
			if ( count( $instances ) >= $max ) {
				$errors[] = 'too_many_instances';
				break;
			}
			if ( ! is_array( $raw ) ) {
				continue;
			}

			$type = (string) ( $raw['type'] ?? '' );
			if ( '' === $type || ! SchemaInstanceFieldSanitizer::is_guided_or_manual_product_type( $type ) ) {
				$errors[] = 'unknown_type';
				continue;
			}

			$id = (string) ( $raw['id'] ?? '' );
			if ( '' === $id || ! self::is_acceptable_instance_id( $id ) ) {
				$id = self::generate_instance_id();
			}
			$id = strtolower( $id );
			if ( isset( $seen_ids[ $id ] ) ) {
				$errors[] = 'duplicate_instance_id';
				$id       = self::generate_instance_id();
			}
			$seen_ids[ $id ] = true;

			$field_result = SchemaInstanceFieldSanitizer::sanitize( $type, $raw['fields'] ?? array(), $context );
			if ( ! $field_result['ok'] ) {
				$errors = array_merge( $errors, $field_result['errors'] );
				continue;
			}

			$now      = time();
			$instance = array(
				'id'         => $id,
				'type'       => $type,
				'source'     => self::sanitize_source( $raw['source'] ?? 'manual' ),
				'fields'     => $field_result['fields'],
				'created_at' => isset( $raw['created_at'] ) ? max( 0, (int) $raw['created_at'] ) : $now,
				'updated_at' => isset( $raw['updated_at'] ) ? max( 0, (int) $raw['updated_at'] ) : $now,
			);

			if ( isset( $raw['source_meta'] ) && is_array( $raw['source_meta'] ) ) {
				$instance['source_meta'] = self::sanitize_source_meta( $raw['source_meta'] );
			}
			if ( isset( $raw['migration'] ) && is_array( $raw['migration'] ) ) {
				$instance['migration'] = self::sanitize_migration_meta( $raw['migration'] );
			}

			$instances[] = self::apply_derived_readiness( $instance );
		}

		if ( in_array( 'payload_too_large', $errors, true ) || in_array( 'max_depth_exceeded', $errors, true ) ) {
			return array(
				'ok'       => false,
				'document' => self::empty_document(),
				'errors'   => array_values( array_unique( $errors ) ),
			);
		}

		return array(
			'ok'       => true,
			'document' => array(
				'version'   => self::DOCUMENT_VERSION,
				'instances' => $instances,
			),
			'errors'   => array_values( array_unique( $errors ) ),
		);
	}

	/**
	 * Recompute readiness and attach derived status (never trust client).
	 *
	 * @param array<string,mixed> $instance Instance.
	 * @return array<string,mixed>
	 */
	public static function apply_derived_readiness( array $instance ) {
		// Strip any client-supplied readiness/status before recompute.
		unset( $instance['status'], $instance['readiness'], $instance['ready'] );

		$type   = (string) ( $instance['type'] ?? '' );
		$fields = isset( $instance['fields'] ) && is_array( $instance['fields'] ) ? $instance['fields'] : array();
		$result = SchemaReadinessEvaluator::evaluate(
			$type,
			$fields,
			array(
				'enforce_visibility' => true,
				'source_meta'        => isset( $instance['source_meta'] ) && is_array( $instance['source_meta'] ) ? $instance['source_meta'] : array(),
			)
		);

		$instance['status'] = $result->is_ready() ? 'ready' : 'draft';
		// Compact readiness snapshot for editors (derived only).
		$instance['readiness'] = array(
			'ready'               => $result->is_ready(),
			'policy_version'      => $result->get_policy_version(),
			'blocker_count'       => count( $result->get_blockers() ),
			'improvement_count'   => count( $result->get_improvements() ),
		);

		return $instance;
	}

	/**
	 * Assert the post may receive instance writes.
	 *
	 * @param int   $post_id Post ID.
	 * @param array $context Context.
	 * @return array{ok:bool,errors:string[]}
	 */
	private static function assert_writable_post( $post_id, array $context ) {
		if ( $post_id <= 0 || ! get_post( $post_id ) ) {
			return array(
				'ok'     => false,
				'errors' => array( 'invalid_post' ),
			);
		}

		$post = get_post( $post_id );
		if ( $post && ( 'revision' === $post->post_type || wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) ) {
			return array(
				'ok'     => false,
				'errors' => array( 'revision_or_autosave_not_allowed' ),
			);
		}

		if ( empty( $context['skip_cap_check'] ) ) {
			$user_id = isset( $context['actor_user_id'] ) ? (int) $context['actor_user_id'] : get_current_user_id();
			if ( $user_id > 0 ) {
				if ( ! user_can( $user_id, 'edit_post', $post_id ) ) {
					return array(
						'ok'     => false,
						'errors' => array( 'forbidden' ),
					);
				}
			} elseif ( ! current_user_can( 'edit_post', $post_id ) ) {
				return array(
					'ok'     => false,
					'errors' => array( 'forbidden' ),
				);
			}
		}

		return array(
			'ok'     => true,
			'errors' => array(),
		);
	}

	/**
	 * Normalize a single instance shape without field sanitization.
	 *
	 * @param mixed $instance Raw.
	 * @return array<string,mixed>|null
	 */
	private static function normalize_instance_shape( $instance ) {
		if ( ! is_array( $instance ) ) {
			return null;
		}
		$type = (string) ( $instance['type'] ?? '' );
		if ( '' === $type ) {
			return null;
		}
		$id = (string) ( $instance['id'] ?? '' );
		if ( '' === $id ) {
			return null;
		}

		$out = array(
			'id'         => $id,
			'type'       => $type,
			'source'     => self::sanitize_source( $instance['source'] ?? 'manual' ),
			'fields'     => isset( $instance['fields'] ) && is_array( $instance['fields'] ) ? $instance['fields'] : array(),
			'created_at' => isset( $instance['created_at'] ) ? (int) $instance['created_at'] : 0,
			'updated_at' => isset( $instance['updated_at'] ) ? (int) $instance['updated_at'] : 0,
		);

		if ( isset( $instance['source_meta'] ) && is_array( $instance['source_meta'] ) ) {
			$out['source_meta'] = $instance['source_meta'];
		}
		if ( isset( $instance['migration'] ) && is_array( $instance['migration'] ) ) {
			$out['migration'] = $instance['migration'];
		}
		// Keep legacy status for readers that still look at it, but emission recomputes.
		if ( isset( $instance['status'] ) ) {
			$out['status'] = sanitize_key( (string) $instance['status'] );
		}

		return $out;
	}

	/**
	 * Whether a bare array looks like a list of instances.
	 *
	 * @param array $raw Raw.
	 * @return bool
	 */
	private static function looks_like_instance_list( array $raw ) {
		if ( array() === $raw || array_keys( $raw ) !== range( 0, count( $raw ) - 1 ) ) {
			return false;
		}
		$first = $raw[0];
		return is_array( $first ) && isset( $first['type'] ) && isset( $first['id'] );
	}

	/**
	 * Sanitize source kind (public for endpoint preview mass-assignment guards).
	 *
	 * @param mixed $source Raw.
	 * @return string
	 */
	public static function sanitize_source( $source ) {
		$source = sanitize_key( (string) $source );
		if ( ! in_array( $source, self::ALLOWED_SOURCES, true ) ) {
			return 'manual';
		}
		return $source;
	}

	/**
	 * Sanitize source_meta bag (string scalars only, limited keys).
	 *
	 * @param array $meta Raw.
	 * @return array<string,string>
	 */
	public static function sanitize_source_meta( array $meta ) {
		$clean = array();
		$count = 0;
		foreach ( $meta as $key => $value ) {
			if ( $count >= 20 ) {
				break;
			}
			$key = sanitize_key( (string) $key );
			if ( '' === $key || is_array( $value ) || is_object( $value ) ) {
				continue;
			}
			$clean[ $key ] = sanitize_text_field( (string) $value );
			++$count;
		}
		return $clean;
	}

	/**
	 * Sanitize migration bookkeeping.
	 *
	 * @param array $meta Raw.
	 * @return array{source_id?:string,from?:string}
	 */
	private static function sanitize_migration_meta( array $meta ) {
		$clean = array();
		if ( isset( $meta['source_id'] ) ) {
			$clean['source_id'] = sanitize_text_field( (string) $meta['source_id'] );
		}
		if ( isset( $meta['from'] ) ) {
			$clean['from'] = sanitize_key( (string) $meta['from'] );
		}
		return $clean;
	}

	/**
	 * Max instances per post.
	 *
	 * @return int
	 */
	private static function max_instances() {
		/**
		 * Filters max guided instances per post.
		 *
		 * @param int $default Soft default.
		 */
		$value = (int) apply_filters( 'seovaultseo_schema_instance_max_per_post', self::DEFAULT_MAX_INSTANCES );
		return max( 1, min( $value, self::HARD_MAX_INSTANCES ) );
	}

	/**
	 * Max document bytes.
	 *
	 * @return int
	 */
	private static function document_byte_limit() {
		/**
		 * Filters max instances document bytes.
		 *
		 * @param int $default Soft default.
		 */
		$value = (int) apply_filters( 'seovaultseo_schema_instance_max_document_bytes', self::DEFAULT_MAX_DOCUMENT_BYTES );
		return max( 1024, min( $value, self::HARD_MAX_DOCUMENT_BYTES ) );
	}
}
