<?php
/**
 * Post-editor orchestration for local Schema Health validation.
 *
 * Published-schema counts always describe the canonical emitted graph from
 * SchemaManager::build_result(). Custom JSON syntax / withheld setup is
 * reported separately and must not replace emitted-graph validation.
 *
 * @package SEOvaultSEO\Schema\Validation
 */

namespace SEOvaultSEO\Schema\Validation;

use SEOvaultSEO\Compatibility\Detector;
use SEOvaultSEO\Core\Options;
use SEOvaultSEO\Core\PostMeta;
use SEOvaultSEO\Schema\SchemaBuildResult;
use SEOvaultSEO\Schema\SchemaCustomJson;
use SEOvaultSEO\Schema\SchemaInstanceStorage;
use SEOvaultSEO\Schema\SchemaManager;
use SEOvaultSEO\Schema\SchemaPostMigration;

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

/**
 * Validates and caches the canonical emitted graph used by the frontend renderer.
 */
class PostSchemaValidationService {

	const SCOPE_SAVED_CUSTOM     = 'saved_custom';
	const SCOPE_EDITOR_CUSTOM    = 'editor_custom';
	const SCOPE_EDITOR_GENERATED = 'editor_generated';
	const SCOPE_GENERATED        = 'generated';
	const CACHE_MAX_AGE          = 604800;

	/**
	 * Prevent recursive save-time validation.
	 *
	 * @var bool
	 */
	private static $validating_save = false;

	/**
	 * Local validation engine.
	 *
	 * @var SchemaValidationService
	 */
	private $validator;

	/**
	 * Create the post validation service.
	 *
	 * @param SchemaValidationService|null $validator Optional validation engine.
	 */
	public function __construct( $validator = null ) {
		$this->validator = $validator instanceof SchemaValidationService ? $validator : new SchemaValidationService();
	}

	/**
	 * Register save-time validation after the meta box save callback.
	 *
	 * @return void
	 */
	public static function init() {
		add_action( 'save_post', array( __CLASS__, 'validate_on_save' ), 20, 2 );
	}

	/**
	 * Validate the final stored schema state after SEOvault post meta is saved.
	 *
	 * @param int      $post_id Post ID.
	 * @param \WP_Post $post    Saved post.
	 * @return void
	 */
	public static function validate_on_save( $post_id, $post ) {
		if ( self::$validating_save || ! $post instanceof \WP_Post ) {
			return;
		}
		if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
			return;
		}
		if ( ! SchemaValidationFeatures::is_enabled( SchemaValidationFeatures::LOCAL ) ) {
			return;
		}
		if ( ! self::is_supported_post_type( $post->post_type ) ) {
			return;
		}
		if ( ! Detector::should_output_seo() || ! Options::is_schema_enabled() ) {
			SchemaValidationCache::delete_local( $post_id );
			return;
		}

		self::$validating_save = true;
		SchemaValidationInvalidator::begin_quiet();
		try {
			( new self() )->validate_saved( $post_id, true );
		} finally {
			SchemaValidationInvalidator::end_quiet();
			self::$validating_save = false;
		}
	}

	/**
	 * Whether a post type is eligible for Schema Health validation.
	 *
	 * @param string $post_type Post type slug.
	 * @return bool
	 */
	public static function is_supported_post_type( $post_type ) {
		$post_type = (string) $post_type;
		return in_array( $post_type, self::get_supported_post_types(), true );
	}

	/**
	 * Supported post types for Schema Health.
	 *
	 * @return string[]
	 */
	public static function get_supported_post_types() {
		$types = get_post_types( array( 'public' => true ), 'names' );
		if ( ! is_array( $types ) ) {
			$types = array();
		}

		/**
		 * Filters post types eligible for Schema Health validation.
		 *
		 * @param string[] $types Public post type names.
		 */
		$filtered = apply_filters( 'seovaultseo_schema_validation_supported_post_types', array_values( $types ) );
		if ( ! is_array( $filtered ) ) {
			return array();
		}

		$out = array();
		foreach ( $filtered as $type ) {
			$type = is_string( $type ) ? sanitize_key( $type ) : '';
			if ( '' !== $type ) {
				$out[] = $type;
			}
		}

		return array_values( array_unique( $out ) );
	}

	/**
	 * Validate the most recently saved schema state for a post.
	 *
	 * Published validation always uses SchemaManager::build_result() emitted graph.
	 *
	 * @param int  $post_id Post ID.
	 * @param bool $force   Skip a matching cached result.
	 * @return array
	 */
	public function validate_saved( $post_id, $force = false ) {
		if ( ! SchemaValidationFeatures::is_enabled( SchemaValidationFeatures::LOCAL ) ) {
			return $this->empty_response( 'unavailable', self::SCOPE_GENERATED );
		}
		if ( ! Options::is_schema_enabled() || ! Detector::should_output_seo() ) {
			return $this->empty_response( 'no_schema', self::SCOPE_GENERATED );
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post ) {
			return $this->empty_response( 'unavailable', self::SCOPE_GENERATED );
		}
		if ( ! self::is_supported_post_type( $post->post_type ) ) {
			return $this->empty_response( 'unavailable', self::SCOPE_GENERATED );
		}

		$build       = SchemaManager::get_instance()->build_result(
			$post,
			array( 'bypass_cache' => true )
		);
		$graph       = $build->get_graph();
		$custom_json = PostMeta::get_schema_json( $post_id );
		$custom_meta = $this->inspect_custom_json( $custom_json, $post_id, $graph );
		$scope       = $custom_meta['emitted'] ? self::SCOPE_SAVED_CUSTOM : self::SCOPE_GENERATED;
		$source      = $custom_meta['emitted'] ? 'custom' : 'generated';
		$cache_key   = $this->get_cache_key(
			array(
				'graph'         => $graph,
				'graph_hash'    => $build->get_graph_hash(),
				'custom_fp'     => $custom_meta['fingerprint'],
				'withheld_fp'   => $this->withheld_fingerprint( $build ),
				'instances_fp'  => $this->instances_fingerprint( $post_id ),
				'content_fp'    => $this->fingerprint_string( (string) $post->post_content ),
				'dismiss_fp'    => \SEOvaultSEO\Schema\Content\SchemaContentSuggestionStore::fingerprint( $post_id ),
			),
			$source
		);

		if ( ! $force ) {
			$cached = $this->get_cached_response( $post_id, $cache_key );
			if ( null !== $cached ) {
				return $cached;
			}
		}

		try {
			$response = $this->validate_emitted_graph( $graph, $source, $scope, $post_id );
			$response = $this->attach_build_and_setup( $response, $build, $custom_json, $post_id, $custom_meta );
			$this->store_cached_response( $post_id, $cache_key, $response );
			return $response;
		} catch ( \Throwable $exception ) {
			return $this->empty_response( 'unavailable', $scope );
		}
	}

	/**
	 * Validate the current custom-schema editor field.
	 *
	 * Empty input previews the generated/emitted graph (without writing cache).
	 * Invalid editor JSON keeps foundational preview as published health and
	 * reports syntax diagnostics under setup.
	 *
	 * @param int    $post_id     Post ID.
	 * @param string $custom_json Raw custom JSON-LD.
	 * @return array
	 */
	public function validate_editor_input( $post_id, $custom_json ) {
		if ( ! SchemaValidationFeatures::is_enabled( SchemaValidationFeatures::LOCAL ) ) {
			return $this->empty_response( 'unavailable', self::SCOPE_EDITOR_CUSTOM );
		}
		if ( ! Options::is_schema_enabled() || ! Detector::should_output_seo() ) {
			return $this->empty_response( 'no_schema', self::SCOPE_EDITOR_CUSTOM );
		}

		$post = get_post( $post_id );
		if ( ! $post instanceof \WP_Post || ! self::is_supported_post_type( $post->post_type ) ) {
			return $this->empty_response( 'unavailable', self::SCOPE_EDITOR_CUSTOM );
		}

		if ( '' === trim( (string) $custom_json ) ) {
			$scope = '' === trim( PostMeta::get_schema_json( $post_id ) )
				? self::SCOPE_GENERATED
				: self::SCOPE_EDITOR_GENERATED;
			$response = $this->validate_generated_preview( $post, $scope );
			$build    = SchemaManager::get_instance()->build_result(
				$post,
				array(
					'ignore_custom_json' => true,
					'bypass_cache'       => true,
				)
			);
			return $this->attach_build_and_setup(
				$response,
				$build,
				'',
				$post_id,
				array(
					'emitted'     => false,
					'valid'       => false,
					'present'     => false,
					'fingerprint' => '',
					'mode'        => '',
				)
			);
		}

		$syntax = $this->validator->validate_custom_json(
			$custom_json,
			array( 'canonical_url' => get_permalink( $post_id ) )
		);
		$syntax_data = $syntax->to_array();

		if ( 'invalid' === (string) ( $syntax_data['status'] ?? '' ) || (int) ( $syntax_data['errors'] ?? 0 ) > 0 ) {
			// Invalid editor custom JSON is setup-only; published health previews generated output.
			$response = $this->validate_generated_preview( $post, self::SCOPE_EDITOR_CUSTOM );
			$build    = SchemaManager::get_instance()->build_result(
				$post,
				array(
					'ignore_custom_json' => true,
					'bypass_cache'       => true,
				)
			);
			$response = $this->attach_build_and_setup(
				$response,
				$build,
				$custom_json,
				$post_id,
				array(
					'emitted'     => false,
					'valid'       => false,
					'present'     => true,
					'fingerprint' => $this->fingerprint_string( (string) $custom_json ),
					'mode'        => '',
				),
				$syntax_data
			);
			return $response;
		}

		// Valid editor custom JSON previews the graph that would be emitted on save.
		try {
			$result_data = $syntax_data;
			$response    = array(
				'state'  => $result_data['status'],
				'scope'  => self::SCOPE_EDITOR_CUSTOM,
				'result' => $result_data,
			);
			$decoded     = json_decode( (string) $custom_json, true );
			$graph       = array();
			if ( is_array( $decoded ) ) {
				if ( isset( $decoded['@graph'] ) && is_array( $decoded['@graph'] ) ) {
					$graph = array(
						'@context' => isset( $decoded['@context'] ) ? $decoded['@context'] : 'https://schema.org',
						'@graph'   => $decoded['@graph'],
					);
				} else {
					$graph = array(
						'@context' => isset( $decoded['@context'] ) ? $decoded['@context'] : 'https://schema.org',
						'@graph'   => array( $decoded ),
					);
				}
			}
			$build = SchemaBuildResult::from_graph( $graph );

			return $this->attach_build_and_setup(
				$response,
				$build,
				$custom_json,
				$post_id,
				array(
					'emitted'     => ! empty( $graph ),
					'valid'       => true,
					'present'     => true,
					'fingerprint' => $this->fingerprint_string( (string) $custom_json ),
					'mode'        => 'editor_preview',
				)
			);
		} catch ( \Throwable $exception ) {
			return $this->empty_response( 'unavailable', self::SCOPE_EDITOR_CUSTOM );
		}
	}

	/**
	 * Validate a generated graph preview without reading or writing saved cache.
	 *
	 * @param \WP_Post $post  Post being previewed.
	 * @param string   $scope Response scope.
	 * @return array
	 */
	private function validate_generated_preview( $post, $scope ) {
		try {
			$build = SchemaManager::get_instance()->build_result(
				$post,
				array(
					'ignore_custom_json' => true,
					'bypass_cache'       => true,
				)
			);
			$graph = $build->get_graph();
			return $this->validate_emitted_graph( $graph, 'generated', $scope, (int) $post->ID );
		} catch ( \Throwable $exception ) {
			return $this->empty_response( 'unavailable', $scope );
		}
	}

	/**
	 * Validate only the emitted public graph.
	 *
	 * @param array  $graph   Emitted graph.
	 * @param string $source  Validation source.
	 * @param string $scope   Response scope.
	 * @param int    $post_id Post ID.
	 * @return array
	 */
	private function validate_emitted_graph( array $graph, $source, $scope, $post_id ) {
		if ( empty( $graph ) ) {
			return $this->empty_response( 'no_schema', $scope );
		}

		$result      = $this->validator->validate(
			$graph,
			$source,
			array( 'canonical_url' => get_permalink( $post_id ) )
		);
		$result_data = $result->to_array();

		return array(
			'state'  => $result_data['status'],
			'scope'  => $scope,
			'result' => $result_data,
		);
	}

	/**
	 * Attach build metadata and separate setup diagnostics.
	 *
	 * @param array              $response    Published validation response.
	 * @param SchemaBuildResult  $build       Canonical build result.
	 * @param string             $custom_json Raw custom JSON (editor or saved).
	 * @param int                $post_id     Post ID.
	 * @param array              $custom_meta Custom JSON inspection.
	 * @param array|null         $syntax_data Optional precomputed custom syntax result.
	 * @return array
	 */
	private function attach_build_and_setup( array $response, SchemaBuildResult $build, $custom_json, $post_id, array $custom_meta, $syntax_data = null ) {
		$withheld = array();
		$seen_ids = array();
		foreach ( $build->get_withheld() as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$diagnostics = array();
			$raw_setup   = isset( $item['setup'] ) && is_array( $item['setup'] ) ? $item['setup'] : array();
			if ( empty( $raw_setup ) && isset( $item['diagnostics'] ) && is_array( $item['diagnostics'] ) ) {
				$raw_setup = $item['diagnostics'];
			}
			$first_field_key = '';
			foreach ( $raw_setup as $diag ) {
				if ( ! is_array( $diag ) ) {
					continue;
				}
				$field_key = (string) ( $diag['field_key'] ?? '' );
				$diagnostics[] = array(
					'code'       => (string) ( $diag['code'] ?? '' ),
					'kind'       => (string) ( $diag['kind'] ?? '' ),
					'field_key'  => $field_key,
					'action_key' => (string) ( $diag['action_key'] ?? '' ),
				);
				if ( '' === $first_field_key && '' !== $field_key ) {
					$first_field_key = $field_key;
				}
			}
			$id         = (string) ( $item['id'] ?? '' );
			$source_ref = (string) ( $item['source_ref'] ?? '' );
			$instance_id = $this->extract_instance_id( $item );
			if ( '' !== $instance_id ) {
				$seen_ids[ $instance_id ] = true;
			} elseif ( '' !== $id ) {
				$seen_ids[ $id ] = true;
			}
			$withheld[] = array(
				'type'            => (string) ( $item['type'] ?? '' ),
				'id'              => $id,
				'instance_id'     => $instance_id,
				'source'          => (string) ( $item['source'] ?? '' ),
				'source_ref'      => $source_ref,
				'reason'          => (string) ( $item['reason'] ?? '' ),
				'status'          => 'withheld',
				'setup'           => $diagnostics,
				'first_field_key' => $first_field_key,
				'editable'        => '' !== $instance_id,
			);
		}

		$guided = SchemaInstanceStorage::export_editor_setup( (int) $post_id );
		foreach ( $guided['unfinished'] as $row ) {
			$instance_id = (string) ( $row['instance_id'] ?? '' );
			if ( '' !== $instance_id && isset( $seen_ids[ $instance_id ] ) ) {
				continue;
			}
			if ( '' !== $instance_id ) {
				$seen_ids[ $instance_id ] = true;
			}
			$withheld[] = $row;
		}

		$improvements = array();
		foreach ( $build->get_improvements() as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$improvements[] = array(
				'code'       => (string) ( $row['code'] ?? '' ),
				'kind'       => (string) ( $row['kind'] ?? 'improvement' ),
				'field_key'  => (string) ( $row['field_key'] ?? '' ),
				'action_key' => (string) ( $row['action_key'] ?? '' ),
				'type'       => (string) ( $row['type'] ?? '' ),
				'source'     => (string) ( $row['source'] ?? '' ),
			);
		}

		$setup = array(
			'withheld'        => $withheld,
			'ready_instances' => isset( $guided['ready'] ) && is_array( $guided['ready'] ) ? $guided['ready'] : array(),
			'custom_json'     => null,
		);

		if ( ! empty( $custom_meta['present'] ) && empty( $custom_meta['emitted'] ) ) {
			if ( null === $syntax_data ) {
				$syntax_data = $this->validator->validate_custom_json(
					$custom_json,
					array( 'canonical_url' => get_permalink( $post_id ) )
				)->to_array();
			}
			$status = 'invalid';
			if ( 'invalid_withheld' === (string) ( $custom_meta['mode'] ?? '' ) ) {
				$status = 'withheld';
			} elseif ( 'needs_confirmation' === (string) ( $custom_meta['action'] ?? '' ) ) {
				$status = 'withheld';
			}
			$setup['custom_json'] = array(
				'status' => $status,
				'mode'   => (string) ( $custom_meta['mode'] ?? '' ),
				'state'  => (string) ( $syntax_data['status'] ?? ( 'needs_confirmation' === (string) ( $custom_meta['action'] ?? '' ) ? 'needs_confirmation' : 'invalid' ) ),
				'errors' => (int) ( $syntax_data['errors'] ?? 0 ),
				'issues' => isset( $syntax_data['issues'] ) && is_array( $syntax_data['issues'] ) ? $syntax_data['issues'] : array(),
				'action' => (string) ( $custom_meta['action'] ?? '' ),
				'ux'     => \SEOvaultSEO\Schema\SchemaCustomJson::describe_ux_state( (int) $post_id, $custom_json ),
			);
		} elseif ( ! empty( $custom_meta['emitted'] ) ) {
			$setup['custom_json'] = array(
				'status' => 'emitted',
				'mode'   => (string) ( $custom_meta['mode'] ?? 'add' ),
				'state'  => 'valid',
				'errors' => 0,
				'issues' => array(),
				'action' => (string) ( $custom_meta['action'] ?? '' ),
				'ux'     => \SEOvaultSEO\Schema\SchemaCustomJson::describe_ux_state( (int) $post_id, $custom_json ),
			);
		}

		$response['setup'] = $setup;
		$response['build'] = array(
			'graph_hash'     => $build->get_graph_hash(),
			'emitted_count'  => count( $build->get_emitted() ),
			'withheld_count' => count( $withheld ),
			'improvements'   => $improvements,
			'emitted'        => $this->export_emitted_for_review( $build->get_emitted() ),
			'conflicts'      => $this->export_conflicts_for_review( $build->get_conflicts() ),
		);
		$response['post_id'] = (int) $post_id;
		$response['content_suggestions'] = \SEOvaultSEO\Schema\Content\SchemaContentSuggestionDetector::detect_for_post( (int) $post_id );

		return $response;
	}

	/**
	 * Compact emitted descriptors for Review schema (no graph payloads).
	 *
	 * @param array $emitted Emitted descriptors.
	 * @return array<int,array>
	 */
	private function export_emitted_for_review( array $emitted ) {
		$out = array();
		foreach ( $emitted as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$out[] = array(
				'type'        => (string) ( $item['type'] ?? '' ),
				'entity_id'   => (string) ( $item['entity_id'] ?? '' ),
				'source'      => (string) ( $item['source'] ?? '' ),
				'source_ref'  => (string) ( $item['source_ref'] ?? '' ),
				'source_id'   => (string) ( $item['source_id'] ?? '' ),
				'graph_index' => isset( $item['graph_index'] ) ? (int) $item['graph_index'] : null,
			);
		}
		return $out;
	}

	/**
	 * Compact conflict records for Review schema (no full payloads).
	 *
	 * @param array $conflicts Conflict diagnostics.
	 * @return array<int,array>
	 */
	private function export_conflicts_for_review( array $conflicts ) {
		$out = array();
		foreach ( $conflicts as $row ) {
			if ( ! is_array( $row ) ) {
				continue;
			}
			$winner_source = '';
			if ( isset( $row['winner'] ) && is_array( $row['winner'] ) ) {
				$winner_source = (string) ( $row['winner']['source'] ?? '' );
			}
			$loser_sources = array();
			if ( isset( $row['losers'] ) && is_array( $row['losers'] ) ) {
				foreach ( $row['losers'] as $loser ) {
					if ( is_array( $loser ) && ! empty( $loser['source'] ) ) {
						$loser_sources[] = (string) $loser['source'];
					}
				}
			}
			$sources = isset( $row['sources'] ) && is_array( $row['sources'] ) ? $row['sources'] : array();
			$out[]   = array(
				'code'          => (string) ( $row['code'] ?? 'schema.source.id_conflict' ),
				'entity_id'     => (string) ( $row['entity_id'] ?? '' ),
				'type'          => (string) ( $row['type'] ?? '' ),
				'reason'        => (string) ( $row['reason'] ?? '' ),
				'sources'       => array_values( array_map( 'strval', $sources ) ),
				'winner_source' => $winner_source,
				'loser_sources' => array_values( array_unique( $loser_sources ) ),
			);
		}
		return $out;
	}

	/**
	 * Inspect saved/editor custom JSON for emission vs setup handling.
	 *
	 * @param string $custom_json Raw JSON.
	 * @param int    $post_id     Post ID.
	 * @param array  $graph       Emitted graph from build_result.
	 * @return array{present:bool,valid:bool,emitted:bool,mode:string,fingerprint:string}
	 */
	private function inspect_custom_json( $custom_json, $post_id, array $graph = array() ) {
		$custom_json = is_string( $custom_json ) ? $custom_json : '';
		$present     = '' !== trim( $custom_json );
		$decision    = SchemaCustomJson::resolve_emission( (int) $post_id, $custom_json );
		$mode        = (string) ( $decision['mode'] ?? SchemaPostMigration::get_json_mode( $post_id ) );
		$fingerprint = $present ? $this->fingerprint_string( $custom_json ) : '';

		if ( ! $present ) {
			return array(
				'present'     => false,
				'valid'       => false,
				'emitted'     => false,
				'mode'        => $mode,
				'fingerprint' => '',
				'action'      => (string) ( $decision['action'] ?? 'none' ),
			);
		}

		if ( 'invalid_withheld' === $mode || 'withhold' === ( $decision['action'] ?? '' ) ) {
			return array(
				'present'     => true,
				'valid'       => false,
				'emitted'     => false,
				'mode'        => $mode,
				'fingerprint' => $fingerprint,
				'action'      => 'withhold',
			);
		}

		$valid   = ! empty( $decision['valid'] );
		$emitted = $valid && in_array( (string) ( $decision['action'] ?? '' ), array( 'add', 'replace' ), true ) && ! empty( $graph );

		return array(
			'present'     => true,
			'valid'       => $valid,
			'emitted'     => $emitted,
			'mode'        => $mode ? $mode : ( $valid ? SchemaCustomJson::MODE_ADD : '' ),
			'fingerprint' => $fingerprint,
			'action'      => (string) ( $decision['action'] ?? '' ),
		);
	}

	/**
	 * Stable fingerprint for withheld descriptors (cache key input).
	 *
	 * @param SchemaBuildResult $build Build result.
	 * @return string
	 */
	private function withheld_fingerprint( SchemaBuildResult $build ) {
		$parts = array();
		foreach ( $build->get_withheld() as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$parts[] = (string) ( $item['type'] ?? '' ) . '|' . (string) ( $item['id'] ?? '' ) . '|' . (string) ( $item['reason'] ?? '' );
		}
		sort( $parts, SORT_STRING );
		return $this->fingerprint_string( implode( ';', $parts ) );
	}

	/**
	 * Fingerprint guided instance document for validation cache keys.
	 *
	 * @param int $post_id Post ID.
	 * @return string
	 */
	private function instances_fingerprint( $post_id ) {
		$raw = get_post_meta( (int) $post_id, SchemaInstanceStorage::META_KEY, true );
		if ( '' === $raw || null === $raw || false === $raw ) {
			return $this->fingerprint_string( '' );
		}
		$encoded = is_string( $raw ) ? $raw : wp_json_encode( $raw );
		return $this->fingerprint_string( is_string( $encoded ) ? $encoded : '' );
	}

	/**
	 * Extract a guided instance UUID from a withheld/emitted descriptor.
	 *
	 * @param array $item Descriptor.
	 * @return string
	 */
	private function extract_instance_id( array $item ) {
		if ( ! empty( $item['instance_id'] ) ) {
			return (string) $item['instance_id'];
		}
		$ref = (string) ( $item['source_ref'] ?? '' );
		if ( 0 === strpos( $ref, 'instance:' ) ) {
			return substr( $ref, strlen( 'instance:' ) );
		}
		$id = (string) ( $item['id'] ?? '' );
		if ( SchemaInstanceStorage::is_acceptable_instance_id( $id ) ) {
			return $id;
		}
		return '';
	}

	/**
	 * Hash a string for cache fingerprints.
	 *
	 * @param string $value Raw value.
	 * @return string
	 */
	private function fingerprint_string( $value ) {
		return 'sha256:' . hash( 'sha256', (string) $value );
	}

	/**
	 * Build a cache key from graph/input, rules, plugin, and relevant options.
	 *
	 * @param mixed  $input  Graph or composite cache payload.
	 * @param string $source Validation source.
	 * @return string
	 */
	private function get_cache_key( $input, $source ) {
		$key_data = array(
			'input'          => $this->canonicalize( $input ),
			'source'         => $source,
			'ruleset'        => RuleSetVersion::get_current(),
			'plugin_version' => defined( 'SEOVAULT_SEO_VERSION' ) ? SEOVAULT_SEO_VERSION : '',
			'options'        => array(
				'schemas'             => Options::get( 'schemas', array() ),
				'schema_enabled'      => Options::is_schema_enabled(),
				'output_mode'         => Options::get_output_mode(),
				'home_url'            => home_url( '/' ),
				'permalink_structure' => get_option( 'permalink_structure' ),
				'custom_logo'         => get_theme_mod( 'custom_logo' ),
				'generation'          => SchemaValidationCache::get_generation(),
				'architecture'        => \SEOvaultSEO\Schema\SchemaArchitectureSettings::get_architecture_version(),
				'policy_version'      => \SEOvaultSEO\Schema\Readiness\SchemaReadinessEvaluator::POLICY_VERSION,
				'publisher_identity'  => \SEOvaultSEO\Schema\SchemaArchitectureSettings::get_publisher_identity(),
				'automatic'           => \SEOvaultSEO\Schema\SchemaArchitectureSettings::get_automatic_components(),
			),
		);

		$key_data = $this->canonicalize( $key_data );
		$encoded  = wp_json_encode( $key_data );
		if ( false === $encoded ) {
			$encoded = serialize( $key_data ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Hash input only; it is never unserialized.
		}

		return 'sha256:' . hash( 'sha256', $encoded );
	}

	/**
	 * Return a fresh, structurally valid cached response.
	 *
	 * @param int    $post_id  Post ID.
	 * @param string $cache_key Expected cache key.
	 * @return array|null
	 */
	private function get_cached_response( $post_id, $cache_key ) {
		$stored_key = PostMeta::get( $post_id, 'schema_validation_hash', '' );
		$stored_at  = (int) PostMeta::get( $post_id, 'schema_validation_time', 0 );
		$response   = PostMeta::get( $post_id, 'schema_validation_result', array() );
		$has_row    = ( is_string( $stored_key ) && '' !== $stored_key )
			|| $stored_at > 0
			|| ( is_array( $response ) && ! empty( $response ) );

		if ( ! is_string( $stored_key ) || ! hash_equals( $cache_key, $stored_key ) ) {
			if ( $has_row ) {
				SchemaValidationCache::delete_local( $post_id );
			}
			return null;
		}
		if ( $stored_at <= 0 || ( time() - $stored_at ) > self::CACHE_MAX_AGE ) {
			SchemaValidationCache::delete_local( $post_id );
			return null;
		}
		if ( ! $this->is_valid_cached_response( $response ) ) {
			SchemaValidationCache::delete_local( $post_id );
			return null;
		}

		return $response;
	}

	/**
	 * Persist a validated response for the post.
	 *
	 * @param int    $post_id   Post ID.
	 * @param string $cache_key Cache key.
	 * @param array  $response  Response payload.
	 * @return void
	 */
	private function store_cached_response( $post_id, $cache_key, $response ) {
		PostMeta::set( $post_id, 'schema_validation_hash', $cache_key );
		PostMeta::set( $post_id, 'schema_validation_time', time() );
		PostMeta::set( $post_id, 'schema_validation_ruleset', RuleSetVersion::get_current() );
		PostMeta::set( $post_id, 'schema_validation_result', $response );
	}

	/**
	 * Delete cached local validation for a post.
	 *
	 * @param int $post_id Post ID.
	 * @return void
	 */
	public static function delete_cache( $post_id ) {
		SchemaValidationCache::delete_local( $post_id );
	}

	/**
	 * Validate the minimum response shape before it reaches the UI.
	 *
	 * @param mixed $response Stored response.
	 * @return bool
	 */
	private function is_valid_cached_response( $response ) {
		if ( ! is_array( $response ) || ! isset( $response['state'], $response['scope'], $response['result'] ) ) {
			return false;
		}
		if ( ! is_string( $response['state'] ) || ! is_string( $response['scope'] ) || ! is_array( $response['result'] ) ) {
			return false;
		}
		if ( ! in_array( $response['state'], array( 'valid', 'valid_with_recommendations', 'warning', 'invalid', 'no_schema' ), true ) ) {
			return false;
		}
		if ( ! in_array( $response['scope'], array( self::SCOPE_GENERATED, self::SCOPE_SAVED_CUSTOM ), true ) ) {
			return false;
		}

		$result = $response['result'];
		if ( 'no_schema' === $response['state'] ) {
			return isset( $result['status'] ) && 'no_schema' === $result['status'];
		}

		return isset( $result['status'], $result['issues'], $result['rule_set_version'] )
			&& is_string( $result['status'] )
			&& is_array( $result['issues'] )
			&& $response['state'] === $result['status']
			&& RuleSetVersion::get_current() === $result['rule_set_version'];
	}

	/**
	 * Sort associative keys recursively for stable cache keys.
	 *
	 * @param mixed $value Value to canonicalize.
	 * @return mixed
	 */
	private function canonicalize( $value ) {
		if ( ! is_array( $value ) ) {
			return $value;
		}

		if ( ! ValueInspector::is_list( $value ) ) {
			ksort( $value, SORT_STRING );
		}
		foreach ( $value as $key => $item ) {
			$value[ $key ] = $this->canonicalize( $item );
		}

		return $value;
	}

	/**
	 * Build a structured response when validation did not run.
	 *
	 * @param string $state Result state.
	 * @param string $scope Validation scope.
	 * @return array
	 */
	private function empty_response( $state, $scope ) {
		$result           = ( new ValidationResult( 'generated' ) )->to_array();
		$result['status'] = $state;

		return array(
			'state'  => $state,
			'scope'  => $scope,
			'result' => $result,
			'setup'  => array(
				'withheld'    => array(),
				'custom_json' => null,
			),
			'build'  => array(
				'graph_hash'     => '',
				'emitted_count'  => 0,
				'withheld_count' => 0,
				'improvements'   => array(),
				'emitted'        => array(),
				'conflicts'      => array(),
			),
			'content_suggestions' => array(
				'content_hash' => '',
				'suggestions'  => array(),
			),
		);
	}
}
