<?php
/**
 * Schema Manager
 *
 * Canonical orchestrator: builds foundational + explicit sources via
 * {@see build_result()}, validates readiness, renders JSON-LD on wp_head.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

use SEOvaultSEO\Blocks\FaqBlockCollector;
use SEOvaultSEO\Blocks\HowToBlockCollector;
use SEOvaultSEO\Utils\Logger;
use SEOvaultSEO\Compatibility\Detector;

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

class SchemaManager {

	private static $instance = null;
	private $schema_data     = array();

	/**
	 * True while JSON-LD output is in progress (blocks re-entrant output_schema).
	 *
	 * @var bool
	 */
	private static $rendering = false;

	/**
	 * True while collecting schema via seovaultseo_set_schema (blocks re-entrant collection).
	 *
	 * @var bool
	 */
	private static $collecting = false;

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

	private function __construct() {}

	public static function init() {
		// Admin AJAX handler always available.
		add_action( 'wp_ajax_seovaultseo_save_schema', array( __CLASS__, 'ajax_save_schema' ) );

		// V2 guided instance meta registration (not exposed via REST).
		SchemaInstanceStorage::init();

		// V2 guided instance CRUD + preview (AJAX + connector REST).
		SchemaInstanceEndpoints::init();

		// Initialize schema templates (custom post type + admin).
		SchemaTemplate::init();

		// Validate after the meta box save callback (priority 10) has stored schema data.
		\SEOvaultSEO\Schema\Validation\PostSchemaValidationService::init();
		\SEOvaultSEO\Schema\Validation\SchemaValidationInvalidator::init();

		// Gate frontend output.
		if ( ! Detector::should_output_seo() || ! \SEOvaultSEO\Core\Options::is_schema_enabled() ) {
			return;
		}

		add_action( 'wp', array( __CLASS__, 'set_schema_data' ), 1 );
		add_action( 'wp_head', array( __CLASS__, 'output_schema' ), 20 );
	}

	/**
	 * Set schema data via filter.
	 */
	public static function set_schema_data() {
		self::get_instance()->schema_data = self::collect_schema_data();
	}

	/**
	 * Collect filter-contributed rendered nodes for a specific object.
	 *
	 * @param \WP_Post|\WP_Term|\WP_User|null $object Explicit render object.
	 * @return array
	 */
	private static function collect_schema_data( $object = null ) {
		if ( self::$collecting ) {
			return array();
		}

		self::$collecting = true;
		try {
			try {
				$schemas = apply_filters( 'seovaultseo_set_schema', array(), $object );
			} catch ( \Throwable $e ) {
				unset( $e );
				return array();
			}
			// Hostile/non-array returns are rejected (do not coerce scalars into emission).
			return is_array( $schemas ) ? $schemas : array();
		} finally {
			self::$collecting = false;
		}
	}

	/**
	 * Collect tagged extension contributions (registry + validated legacy filter).
	 *
	 * @param \WP_Post|\WP_Term|\WP_User|null $object Explicit render object.
	 * @return array<int,array{source:string,source_ref:string,schema:array}>
	 */
	private static function collect_extension_tagged_schemas( $object = null ) {
		if ( self::$collecting ) {
			return array();
		}

		self::$collecting = true;
		$tagged            = array();
		try {
			foreach ( SchemaExtensionRegistry::collect_tagged_schemas( $object ) as $row ) {
				if ( is_array( $row ) ) {
					$tagged[] = $row;
				}
			}

			try {
				$filter_return = apply_filters( 'seovaultseo_set_schema', array(), $object );
			} catch ( \Throwable $e ) {
				unset( $e );
				$filter_return = array();
			}

			foreach ( SchemaExtensionRegistry::normalize_set_schema_return( $filter_return ) as $row ) {
				$tagged[] = $row;
			}
		} finally {
			self::$collecting = false;
		}

		return $tagged;
	}

	/**
	 * Check if schemas should be loaded.
	 *
	 * @return bool
	 */
	public static function should_load_schemas() {
		if ( ! \SEOvaultSEO\Core\Options::is_schema_enabled() ) {
			return false;
		}
		if ( ! apply_filters( 'seovaultseo_enable_schemas', true ) ) {
			return false;
		}
		if ( is_admin() && ! wp_doing_ajax() ) {
			return false;
		}
		return true;
	}

	/**
	 * Build the canonical graph consumed by both output and local validation.
	 *
	 * Backward-compatible wrapper around build_result()->get_graph().
	 *
	 * @param int|\WP_Post|\WP_Term|\WP_User|null $object  Object or post ID.
	 * @param array                               $options Build options.
	 * @return array Empty array when no safe graph can be produced.
	 */
	public function build_output_graph( $object = null, $options = array() ) {
		$graph = $this->build_result( $object, $options )->get_graph();
		if ( empty( $graph ) ) {
			return $graph;
		}

		/**
		 * Filter the final schema.org @graph array before emission.
		 *
		 * Integrations (e.g. the Pro Video SEO module) may append additional
		 * nodes to the existing @graph. The envelope is
		 * `['@context' => 'https://schema.org', '@graph' => [...]]`.
		 *
		 * @param array                               $graph  The @graph envelope.
		 * @param \WP_Post|\WP_Term|\WP_User|null     $object The render object.
		 */
		return apply_filters( 'seovaultseo_schema_graph', $graph, $object );
	}

	/**
	 * Canonical schema build API for front-end emission, Schema Health, and live checks.
	 *
	 * This is the single active graph builder. Callers that only need the public
	 * JSON-LD array should use {@see build_output_graph()}, which returns
	 * `build_result()->get_graph()`.
	 *
	 * The result includes source descriptors, conflicts, withheld specialized
	 * setup, and custom JSON decisions. Source metadata is never encoded into
	 * the public `@graph`.
	 *
	 * Known options:
	 * - `ignore_custom_json` (bool) — skip post custom JSON-LD while building.
	 * - `bypass_cache` (bool) — skip request-scoped {@see SchemaBuildCache}.
	 *
	 * Unmigrated posts that still have `_seovault_schemas` do not emit from that
	 * meta until {@see SchemaPostMigration} completes; use Upgrade / admin loads
	 * to finish pending post batches.
	 *
	 * @param int|\WP_Post|\WP_Term|\WP_User|null $object  Object or post ID.
	 * @param array                               $options Build options.
	 * @return SchemaBuildResult
	 */
	public function build_result( $object = null, $options = array() ) {
		$has_explicit_object = null !== $object;
		$resolved            = $this->resolve_object( $object );
		$bypass              = ! empty( $options['bypass_cache'] );
		// Nested builds during extension collection must not poison the request cache.
		$use_cache           = ! $bypass && ! self::$collecting;
		$cache_key           = $use_cache ? SchemaBuildCache::make_key( $resolved, $options ) : '';

		return SchemaBuildCache::remember(
			$cache_key,
			function () use ( $object, $options, $has_explicit_object ) {
				return $this->build_result_uncached( $object, $options, $has_explicit_object );
			},
			$use_cache
		);
	}

	/**
	 * Internal build path without request memoization.
	 *
	 * @param int|\WP_Post|\WP_Term|\WP_User|null $object              Object or post ID.
	 * @param array                               $options             Build options.
	 * @param bool                                $has_explicit_object Whether caller passed an object.
	 * @return SchemaBuildResult
	 */
	private function build_result_uncached( $object, $options, $has_explicit_object ) {
		$object             = $this->resolve_object( $object );
		$ignore_custom_json = ! empty( $options['ignore_custom_json'] );
		$collect_sources    = true;

		$custom_add_tagged      = null;
		$custom_withheld_setup  = null;
		$custom_replace_preview = null;

		if ( $object instanceof \WP_Post && ! $ignore_custom_json ) {
			$custom_json = \SEOvaultSEO\Core\PostMeta::get_schema_json( $object->ID );
			$decision    = SchemaCustomJson::resolve_emission( $object->ID, $custom_json );
			$action      = (string) ( $decision['action'] ?? 'none' );

			if ( 'replace' === $action ) {
				$graph = isset( $decision['graph'] ) && is_array( $decision['graph'] ) ? $decision['graph'] : array();
				if ( empty( $graph ) ) {
					$result = SchemaBuildResult::empty_result();
					return $this->maybe_attach_source_diagnostics(
						$result,
						$collect_sources
							? array(
								SchemaCustomJson::tagged_contribution( $object->ID, array( 'invalid' => true ) ),
							)
							: array()
					);
				}

				$emitted = SchemaBuildResult::descriptors_from_nodes(
					isset( $graph['@graph'] ) && is_array( $graph['@graph'] ) ? $graph['@graph'] : array(),
					'custom'
				);
				$result  = SchemaBuildResult::from_graph( $graph, $emitted );
				return $this->maybe_attach_source_diagnostics(
					$result,
					$collect_sources
						? array(
							SchemaCustomJson::tagged_contribution(
								$object->ID,
								isset( $decision['data'] ) && is_array( $decision['data'] ) ? $decision['data'] : $graph
							),
						)
						: array()
				);
			}

			if ( 'withhold' === $action ) {
				$json_mode     = SchemaCustomJson::get_mode( $object->ID );
				$post_migrated = SchemaPostMigration::is_post_migrated( $object->ID );
				// Phase 11+/53: invalid custom never blanks foundation when migrated or marked withheld.
				// Unmigrated invalid still returns empty for characterization parity.
				if ( SchemaCustomJson::MODE_INVALID_WITHHELD === $json_mode || $post_migrated ) {
					$custom_withheld_setup = $decision;
					// Fall through to generated schema paths.
				} elseif ( ! empty( $decision['present'] ) && empty( $decision['valid'] ) ) {
					$result = SchemaBuildResult::empty_result();
					return $this->maybe_attach_source_diagnostics(
						$result,
						$collect_sources
							? array(
								SchemaCustomJson::tagged_contribution( $object->ID, array( 'invalid' => true ) ),
							)
							: array()
					);
				} else {
					$custom_withheld_setup = $decision;
				}
			}

			if ( 'add' === $action && ! empty( $decision['data'] ) && is_array( $decision['data'] ) ) {
				$custom_add_tagged = SchemaCustomJson::tagged_contribution( $object->ID, $decision['data'] );
			}

			if ( 'needs_confirmation' === $action ) {
				$custom_withheld_setup  = $decision;
				$custom_replace_preview = $decision;
				// Do not emit custom replace until confirmed; foundation continues.
			}
		}

		$collector      = new FoundationalContextCollector();
		$active_schemas = $collector->collect_configs( $object );
		$active_source  = 'foundational';

		$tagged_schemas = array();
		foreach ( $active_schemas as $index => $schema ) {
			$tagged_schemas[] = array(
				'source'     => $active_source,
				'source_ref' => $active_source . ':active:' . (int) $index,
				'schema'     => $schema,
			);
		}

		// Phase 44: ready guided post instances emit alongside foundational entities.
		if ( $object instanceof \WP_Post && SchemaPostMigration::is_post_migrated( $object->ID ) ) {
			foreach ( SchemaInstanceStorage::get_ready_schema_configs( $object->ID ) as $index => $schema ) {
				if ( ! is_array( $schema ) ) {
					continue;
				}
				$tagged_schemas[] = array(
					'source'     => 'post_instance',
					'source_ref' => 'post_instance:ready:' . (int) $index,
					'schema'     => $schema,
				);
			}
		}

		// Phase 50: readiness-aware targeted templates (resolve variables first).
		$template_collection = SchemaTemplateCollector::collect_for_object( $object );
		foreach ( $template_collection['tagged'] as $tagged ) {
			if ( ! is_array( $tagged ) || empty( $tagged['schema'] ) || ! is_array( $tagged['schema'] ) ) {
				continue;
			}
			$tagged_schemas[] = array(
				'source'     => SchemaTemplateCollector::SOURCE,
				'source_ref' => isset( $tagged['source_ref'] ) ? (string) $tagged['source_ref'] : 'template',
				'schema'     => $tagged['schema'],
			);
		}

		// Phase 56: validated extension contributors + legacy seovaultseo_set_schema (forced source tagging).
		if ( $has_explicit_object ) {
			foreach ( self::collect_extension_tagged_schemas( $object ) as $ext_tagged ) {
				if ( is_array( $ext_tagged ) && ! empty( $ext_tagged['schema'] ) && is_array( $ext_tagged['schema'] ) ) {
					$tagged_schemas[] = $ext_tagged;
				}
			}
		} else {
			// Frontend path: schema_data was collected at wp; normalize the cached list.
			foreach ( SchemaExtensionRegistry::normalize_set_schema_return( $this->schema_data ) as $ext_tagged ) {
				$tagged_schemas[] = $ext_tagged;
			}
			foreach ( SchemaExtensionRegistry::collect_tagged_schemas( $object ) as $ext_tagged ) {
				if ( is_array( $ext_tagged ) && ! empty( $ext_tagged['schema'] ) && is_array( $ext_tagged['schema'] ) ) {
					$tagged_schemas[] = $ext_tagged;
				}
			}
		}

		// Phase 52: tagged V2 integration contributors (e.g. WooCommerce Product).
		foreach ( SchemaIntegrationRegistry::collect_tagged_schemas( $object ) as $tagged ) {
			if ( ! is_array( $tagged ) || empty( $tagged['schema'] ) || ! is_array( $tagged['schema'] ) ) {
				continue;
			}
			$tagged_schemas[] = array(
				'source'     => SchemaIntegrationRegistry::SOURCE,
				'source_ref' => isset( $tagged['source_ref'] ) ? (string) $tagged['source_ref'] : SchemaIntegrationRegistry::SOURCE,
				'schema'     => $tagged['schema'],
			);
		}

		// Phase 28: block-derived FAQPage from saved seovault/faq attributes.
		$faq_collection   = null;
		$howto_collection = null;
		if ( $object instanceof \WP_Post ) {
			$faq_collection = FaqBlockCollector::collect_from_post( $object );
			if ( is_array( $faq_collection['node'] ?? null ) && ! empty( $faq_collection['node'] ) ) {
				$tagged_schemas[] = array(
					'source'     => FaqBlockCollector::SOURCE,
					'source_ref' => (string) ( $faq_collection['source_ref'] ?? FaqBlockCollector::SOURCE_REF ),
					'schema'     => $faq_collection['node'],
				);
			}

			// Phase 41: block-derived HowTo nodes (one unique @id per ready block).
			$howto_collection = HowToBlockCollector::collect_from_post( $object );
			$howto_nodes      = isset( $howto_collection['nodes'] ) && is_array( $howto_collection['nodes'] )
				? $howto_collection['nodes']
				: array();
			foreach ( $howto_nodes as $howto_index => $howto_node ) {
				if ( ! is_array( $howto_node ) || empty( $howto_node ) ) {
					continue;
				}
				$howto_ref = HowToBlockCollector::SOURCE_REF;
				if ( isset( $howto_collection['instances'][ $howto_index ]['source_ref'] ) ) {
					$howto_ref = (string) $howto_collection['instances'][ $howto_index ]['source_ref'];
				}
				$tagged_schemas[] = array(
					'source'     => HowToBlockCollector::SOURCE,
					'source_ref' => $howto_ref,
					'schema'     => $howto_node,
				);
			}
		}

		// Phase 53: Add-mode custom JSON merges through the tagged source pipeline.
		if ( is_array( $custom_add_tagged ) ) {
			$tagged_schemas[] = $custom_add_tagged;
		}

		// Phase 52: commerce integrations own Product on real product pages.
		// Suppress competing manual/template/extension Product nodes (custom replace still wins earlier).
		$owned_types = SchemaIntegrationRegistry::owned_types( $object );
		if ( ! empty( $owned_types ) ) {
			$filtered = array();
			foreach ( $tagged_schemas as $tagged ) {
				if ( ! is_array( $tagged ) ) {
					continue;
				}
				// Add-mode custom is never suppressed by integration ownership.
				if ( isset( $tagged['source'] ) && 'custom' === $tagged['source'] ) {
					$filtered[] = $tagged;
					continue;
				}
				if ( SchemaIntegrationRegistry::should_suppress_owned_type( $tagged, $owned_types ) ) {
					continue;
				}
				$filtered[] = $tagged;
			}
			$tagged_schemas = $filtered;
		}

		if ( empty( $tagged_schemas ) ) {
			$result = SchemaBuildResult::empty_result();
			$result = $this->merge_faq_block_diagnostics( $result, $faq_collection );
			$result = $this->merge_howto_block_diagnostics( $result, $howto_collection );
			$result = $this->merge_template_collector_diagnostics( $result, $template_collection );
			$result = $this->merge_custom_json_diagnostics( $result, $custom_withheld_setup, $custom_replace_preview, $object );
			return $this->maybe_attach_source_diagnostics( $result, $collect_sources ? $tagged_schemas : array() );
		}

		$schemas = array();
		foreach ( $tagged_schemas as $tagged ) {
			$schemas[] = $tagged['schema'];
		}

		$rendered         = array();
		$emitted          = array();
		$withheld         = array();
		$improvements     = array();
		$base_renderer    = new SchemaRenderer( $object );
		$schema_links     = $this->build_schema_links( $schemas, $object, $base_renderer );
		$renderer         = new SchemaRenderer( $object, $schema_links );
		$collector        = new FoundationalContextCollector();
		$gate_specialized = true;

		foreach ( $tagged_schemas as $tagged ) {
			$schema     = $tagged['schema'];
			$source     = isset( $tagged['source'] ) ? (string) $tagged['source'] : '';
			$source_ref = isset( $tagged['source_ref'] ) ? (string) $tagged['source_ref'] : '';
			if ( ! is_array( $schema ) ) {
				continue;
			}

			if ( $this->is_raw_schema( $schema ) ) {
				$raw_graph = $this->build_raw_graph( $schema );
				if ( ! empty( $raw_graph['@graph'] ) ) {
					foreach ( $raw_graph['@graph'] as $node ) {
						if ( ! is_array( $node ) || empty( $node ) ) {
							continue;
						}
						$this->accept_or_withhold_node(
							$node,
							$source,
							$source_ref,
							$gate_specialized,
							$collector,
							$rendered,
							$emitted,
							$withheld,
							$improvements
						);
					}
				}
				continue;
			}

			if ( ! SchemaUtils::resolve_schema_enabled( $schema ) ) {
				continue;
			}

			if ( ! self::validate_show_on( $schema, $object ) ) {
				continue;
			}
			if ( self::check_not_show_on( $schema, $object ) ) {
				continue;
			}

			$type = $schema['fields']['@type'] ?? $schema['type'] ?? 'Thing';
			if ( ! empty( $schema['_v2_emit_type'] ) && is_string( $schema['_v2_emit_type'] ) ) {
				$type = $schema['_v2_emit_type'];
			}
			$fields = $schema['fields'] ?? array();
			if ( empty( $fields ) ) {
				$fields = SchemaUtils::parse_fields( $schema );
			}

			$output = self::render_schema( $type, $fields, $renderer );
			if ( empty( $output ) ) {
				continue;
			}

			$nodes = self::is_list_array( $output ) ? $output : array( $output );
			foreach ( $nodes as $node ) {
				if ( ! is_array( $node ) || empty( $node ) ) {
					continue;
				}

				$this->accept_or_withhold_node(
					$node,
					$source,
					$source_ref,
					$gate_specialized,
					$collector,
					$rendered,
					$emitted,
					$withheld,
					$improvements
				);
			}
		}

		if ( empty( $rendered ) ) {
			$result = SchemaBuildResult::empty_result();
			if ( ! empty( $withheld ) || ! empty( $improvements ) ) {
				$result = new SchemaBuildResult( array(), array(), $withheld, $improvements );
			}
			$result = $this->merge_faq_block_diagnostics( $result, $faq_collection );
			$result = $this->merge_howto_block_diagnostics( $result, $howto_collection );
			$result = $this->merge_template_collector_diagnostics( $result, $template_collection );
			$result = $this->merge_custom_json_diagnostics( $result, $custom_withheld_setup, $custom_replace_preview, $object );
			return $this->maybe_attach_source_diagnostics( $result, $collect_sources ? $tagged_schemas : array() );
		}

		$graph = array(
			'@context' => 'https://schema.org',
			'@graph'   => array_values( array_filter( $rendered ) ),
		);

		$result = new SchemaBuildResult(
			$graph,
			$emitted,
			$withheld,
			$improvements
		);

		$result = $this->merge_faq_block_diagnostics( $result, $faq_collection );
		$result = $this->merge_howto_block_diagnostics( $result, $howto_collection );
		$result = $this->merge_template_collector_diagnostics( $result, $template_collection );
		$result = $this->merge_custom_json_diagnostics( $result, $custom_withheld_setup, $custom_replace_preview, $object );

		return $this->maybe_attach_source_diagnostics( $result, $collect_sources ? $tagged_schemas : array() );
	}

	/**
	 * Attach custom JSON withhold / replace-confirmation diagnostics.
	 *
	 * @param SchemaBuildResult               $result                 Current result.
	 * @param array|null                      $withheld_decision      Resolve emission decision.
	 * @param array|null                      $replace_preview        Unconfirmed replace decision.
	 * @param \WP_Post|\WP_Term|\WP_User|null $object                 Build object.
	 * @return SchemaBuildResult
	 */
	private function merge_custom_json_diagnostics( SchemaBuildResult $result, $withheld_decision, $replace_preview, $object ) {
		$extra_withheld     = array();
		$extra_improvements = array();

		if ( is_array( $withheld_decision ) && ! empty( $withheld_decision['present'] ) ) {
			$reason = (string) ( $withheld_decision['reason'] ?? 'custom_json_withheld' );
			$extra_withheld[] = array(
				'type'       => 'CustomJSON',
				'id'         => '',
				'entity_id'  => '',
				'source'     => 'custom',
				'source_ref' => $object instanceof \WP_Post ? 'post:' . (int) $object->ID . ':schema_json' : 'custom',
				'reason'     => $reason,
				'status'     => ( 'replace_unconfirmed' === $reason ) ? 'needs_confirmation' : 'withheld',
				'emitted'    => false,
				'withheld'   => true,
				'mode'       => (string) ( $withheld_decision['mode'] ?? '' ),
			);
		}

		if ( is_array( $replace_preview ) && 'needs_confirmation' === ( $replace_preview['action'] ?? '' ) ) {
			$custom_graph = isset( $replace_preview['graph'] ) && is_array( $replace_preview['graph'] )
				? $replace_preview['graph']
				: array();
			$displaced = SchemaCustomJson::displaced_by_replace( $result->get_graph(), $custom_graph );
			foreach ( $displaced as $row ) {
				if ( ! is_array( $row ) ) {
					continue;
				}
				$extra_improvements[] = array(
					'code'       => 'schema.custom_json.replace_would_remove',
					'kind'       => 'replace_preview',
					'field_key'  => '',
					'action_key' => 'confirm_replace',
					'type'       => (string) ( $row['type'] ?? '' ),
					'entity_id'  => (string) ( $row['entity_id'] ?? '' ),
					'source'     => 'custom',
				);
			}
		}

		if ( empty( $extra_withheld ) && empty( $extra_improvements ) ) {
			return $result;
		}

		return new SchemaBuildResult(
			$result->get_graph(),
			$result->get_emitted(),
			array_merge( $result->get_withheld(), $extra_withheld ),
			array_merge( $result->get_improvements(), $extra_improvements ),
			$result->get_conflicts(),
			$result->get_source_map(),
			$result->get_graph_hash()
		);
	}

	/**
	 * Merge incomplete FAQ block diagnostics into the build result.
	 *
	 * @param SchemaBuildResult $result         Current result.
	 * @param array|null        $faq_collection Collector payload.
	 * @return SchemaBuildResult
	 */
	private function merge_faq_block_diagnostics( SchemaBuildResult $result, $faq_collection ) {
		return $this->merge_block_collector_diagnostics( $result, $faq_collection );
	}

	/**
	 * Merge incomplete HowTo block diagnostics into the build result.
	 *
	 * @param SchemaBuildResult $result           Current result.
	 * @param array|null        $howto_collection Collector payload.
	 * @return SchemaBuildResult
	 */
	private function merge_howto_block_diagnostics( SchemaBuildResult $result, $howto_collection ) {
		return $this->merge_block_collector_diagnostics( $result, $howto_collection );
	}

	/**
	 * Merge withheld/improvement rows from the Phase 50 template collector.
	 *
	 * @param SchemaBuildResult $result             Current result.
	 * @param array|null        $template_collection Collector payload.
	 * @return SchemaBuildResult
	 */
	private function merge_template_collector_diagnostics( SchemaBuildResult $result, $template_collection ) {
		return $this->merge_block_collector_diagnostics( $result, $template_collection );
	}

	/**
	 * Merge withheld/improvement rows from a block collector payload.
	 *
	 * @param SchemaBuildResult $result     Current result.
	 * @param array|null        $collection Collector payload.
	 * @return SchemaBuildResult
	 */
	private function merge_block_collector_diagnostics( SchemaBuildResult $result, $collection ) {
		if ( ! is_array( $collection ) ) {
			return $result;
		}

		$extra_withheld     = isset( $collection['withheld'] ) && is_array( $collection['withheld'] ) ? $collection['withheld'] : array();
		$extra_improvements = isset( $collection['improvements'] ) && is_array( $collection['improvements'] ) ? $collection['improvements'] : array();
		if ( empty( $extra_withheld ) && empty( $extra_improvements ) ) {
			return $result;
		}

		$withheld = array_merge( $result->get_withheld(), $extra_withheld );
		$improve  = array_merge( $result->get_improvements(), $extra_improvements );

		return new SchemaBuildResult(
			$result->get_graph(),
			$result->get_emitted(),
			$withheld,
			$improve,
			$result->get_conflicts(),
			$result->get_source_map(),
			$result->get_graph_hash()
		);
	}

	/**
	 * Apply foundational and/or specialized readiness gates to one candidate node.
	 *
	 * @param array                             $node             Candidate node.
	 * @param string                            $source           Source kind.
	 * @param string                            $source_ref       Source reference.
	 * @param bool                              $gate_specialized Whether specialized gate is on.
	 * @param FoundationalContextCollector|null $collector        Foundational collector when active.
	 * @param array                             $rendered         Rendered nodes (by ref).
	 * @param array                             $emitted          Emitted descriptors (by ref).
	 * @param array                             $withheld         Withheld descriptors (by ref).
	 * @param array                             $improvements     Improvements (by ref).
	 * @return void
	 */
	private function accept_or_withhold_node(
		array $node,
		$source,
		$source_ref,
		$gate_specialized,
		$collector,
		array &$rendered,
		array &$emitted,
		array &$withheld,
		array &$improvements
	) {
		if ( 'foundational' === $source && $collector instanceof FoundationalContextCollector ) {
			$node_type = isset( $node['@type'] ) ? (string) $node['@type'] : 'Thing';
			if ( is_array( $node['@type'] ?? null ) ) {
				$types     = \SEOvaultSEO\Schema\Validation\ValueInspector::get_types( $node );
				$node_type = ! empty( $types ) ? (string) $types[0] : 'Thing';
			}
			if ( ! $collector->is_node_ready( $node_type, $node ) ) {
				$withheld[] = array_merge(
					SchemaBuildResult::descriptor_from_node( $node, $source, count( $rendered ) ),
					array(
						'emitted'  => false,
						'withheld' => true,
						'reason'   => 'foundational_not_ready',
					)
				);
				return;
			}
		}

		if ( $gate_specialized ) {
			$decision = SchemaSpecializedEmissionGate::decide(
				$node,
				$source,
				array(
					'source_ref' => $source_ref,
				)
			);

			if ( ! empty( $decision['improvements'] ) && is_array( $decision['improvements'] ) ) {
				foreach ( $decision['improvements'] as $row ) {
					if ( is_array( $row ) ) {
						$improvements[] = $row;
					}
				}
			}

			if ( 'withhold' === ( $decision['action'] ?? '' ) ) {
				if ( is_array( $decision['withheld'] ?? null ) ) {
					$decision['withheld']['graph_index'] = count( $rendered );
					$withheld[]                          = $decision['withheld'];
				}
				return;
			}

			if ( 'emit' === ( $decision['action'] ?? '' ) && is_array( $decision['node'] ?? null ) && ! empty( $decision['node'] ) ) {
				$node = $decision['node'];
			}
		}

		$emitted[]  = SchemaBuildResult::descriptor_from_node( $node, $source, count( $rendered ), $source_ref );
		$rendered[] = $node;
	}

	/**
	 * Attach V2 source-descriptor / conflict diagnostics without changing the public graph.
	 *
	 * @param SchemaBuildResult $result         Existing result (legacy emission).
	 * @param array             $tagged_schemas Tagged contributions (empty skips).
	 * @return SchemaBuildResult
	 */
	private function maybe_attach_source_diagnostics( SchemaBuildResult $result, array $tagged_schemas ) {
		if ( empty( $tagged_schemas ) ) {
			return $result;
		}

		// Legacy global option schemas intentionally have an empty source kind until
		// foundational context replaces them. Skip those for V2 descriptor collection.
		$collectible = array();
		foreach ( $tagged_schemas as $tagged ) {
			if ( ! is_array( $tagged ) ) {
				continue;
			}
			$source = isset( $tagged['source'] ) ? (string) $tagged['source'] : '';
			if ( '' === $source || ! in_array( $source, SchemaBuildResult::SOURCE_KINDS, true ) ) {
				continue;
			}
			$collectible[] = $tagged;
		}

		if ( empty( $collectible ) ) {
			return $result;
		}

		$resolved   = ( new SchemaSourceCollector() )->resolve_tagged_schemas( $collectible );
		$source_map = isset( $resolved['source_map'] ) && is_array( $resolved['source_map'] )
			? $resolved['source_map']
			: array();
		$conflicts  = isset( $resolved['conflicts'] ) && is_array( $resolved['conflicts'] )
			? $resolved['conflicts']
			: array();

		// Preserve emitted descriptors in the map under emitted:* keys when they would be overwritten.
		foreach ( $result->get_emitted() as $emitted ) {
			if ( ! is_array( $emitted ) ) {
				continue;
			}
			$key = '';
			if ( ! empty( $emitted['entity_id'] ) && is_string( $emitted['entity_id'] ) ) {
				$key = $emitted['entity_id'];
			} elseif ( ! empty( $emitted['source_id'] ) && is_string( $emitted['source_id'] ) ) {
				$key = $emitted['source_id'];
			}
			if ( '' === $key ) {
				continue;
			}
			if ( ! isset( $source_map[ $key ] ) ) {
				$source_map[ $key ] = $emitted;
			} else {
				$source_map[ 'emitted:' . $key ] = $emitted;
			}
		}

		return new SchemaBuildResult(
			$result->get_graph(),
			$result->get_emitted(),
			$result->get_withheld(),
			$result->get_improvements(),
			$conflicts,
			$source_map,
			$result->get_graph_hash()
		);
	}

	/**
	 * Precompute stable @id-only references for active configured entities.
	 *
	 * @param array          $schemas  Schema configurations.
	 * @param mixed          $object   Explicit render object.
	 * @param SchemaRenderer $renderer Base-variable renderer.
	 * @return array
	 */
	private function build_schema_links( $schemas, $object, $renderer ) {
		$links = array();
		foreach ( $schemas as $schema ) {
			if ( ! is_array( $schema ) || $this->is_raw_schema( $schema ) || ! SchemaUtils::resolve_schema_enabled( $schema ) ) {
				continue;
			}
			if ( ! self::validate_show_on( $schema, $object ) || self::check_not_show_on( $schema, $object ) ) {
				continue;
			}

			$type   = (string) ( $schema['type'] ?? '' );
			$fields = $schema['fields'] ?? array();
			if ( empty( $fields ) ) {
				$fields = SchemaUtils::parse_fields( $schema );
			}
			$id = $fields['@id'] ?? '';
			$renderer->render( $id );
			if ( '' !== $type && is_string( $id ) && '' !== trim( $id ) ) {
				$links[ strtolower( $type ) ] = array( '@id' => $id );
			}
		}
		return $links;
	}

	/**
	 * Output schema JSON-LD.
	 */
	public static function output_schema() {
		if ( self::$rendering ) {
			return;
		}

		if ( ! self::should_load_schemas() ) {
			return;
		}

		self::$rendering = true;

		try {
			$instance = self::get_instance();
			$graph    = $instance->build_output_graph();

			if ( empty( $graph ) ) {
				Logger::log( 'SchemaManager::output_schema: no active schemas found' );
				return;
			}

			Logger::log( 'SchemaManager::output_schema: rendered JSON-LD', array( 'nodes' => count( $graph['@graph'] ) ) );

		$encoded = wp_json_encode( $graph, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
			if ( false === $encoded ) {
				return;
			}

			echo '<script type="application/ld+json" id="seovaultseo-schema">' . $encoded . '</script>' . "\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON_HEX_TAG prevents script termination and the graph contains no raw markup.
		} finally {
			self::$rendering = false;
		}
	}

	/**
	 * Resolve an explicit render object without mutating the global query.
	 *
	 * @param int|\WP_Post|\WP_Term|\WP_User|null $object Object or post ID.
	 * @return \WP_Post|\WP_Term|\WP_User|null
	 */
	private function resolve_object( $object ) {
		if ( is_numeric( $object ) ) {
			$object = get_post( (int) $object );
		}

		if ( $object instanceof \WP_Post || $object instanceof \WP_Term || $object instanceof \WP_User ) {
			return $object;
		}

		$queried = get_queried_object();
		if ( $queried instanceof \WP_Post || $queried instanceof \WP_Term || $queried instanceof \WP_User ) {
			return $queried;
		}

		return null;
	}

	/**
	 * Convert parsed JSON-LD into the renderer's stable context/graph envelope.
	 *
	 * @param array $schema Parsed JSON-LD.
	 * @return array
	 */
	private function build_raw_graph( $schema ) {
		$context = 'https://schema.org';
		$nodes   = array();

		if ( self::is_list_array( $schema ) ) {
			$nodes = $schema;
		} elseif ( isset( $schema['@graph'] ) && is_array( $schema['@graph'] ) ) {
			$context = isset( $schema['@context'] ) ? $schema['@context'] : $context;
			$nodes   = $schema['@graph'];
		} else {
			if ( isset( $schema['@context'] ) ) {
				$context = $schema['@context'];
				unset( $schema['@context'] );
			}
			$nodes[] = $schema;
		}

		$nodes = array_values(
			array_filter(
				$nodes,
				static function ( $node ) {
					return is_array( $node ) && ! empty( $node );
				}
			)
		);

		if ( empty( $nodes ) ) {
			return array();
		}

		return array(
			'@context' => $context,
			'@graph'   => $nodes,
		);
	}

	/**
	 * Determine whether an item is already a rendered JSON-LD node or graph.
	 *
	 * @param array $schema Schema item.
	 * @return bool
	 */
	private function is_raw_schema( $schema ) {
		return self::is_list_array( $schema ) || isset( $schema['@type'] ) || isset( $schema['@graph'] );
	}

	/**
	 * AJAX handler: save schema settings.
	 */
	public static function ajax_save_schema() {
		check_ajax_referer( 'seovaultseo_admin', 'nonce' );

		if ( ! current_user_can( 'manage_options' ) ) {
			Logger::error( 'SchemaManager::ajax_save_schema: capability check failed' );
			wp_send_json_error( array( 'message' => __( 'Unauthorized.', 'seovault-ai' ) ), 403 );
		}

		// Legacy catalogue saves are always rejected in the V2-only architecture.
		Logger::log( 'SchemaManager::ajax_save_schema: rejected — legacy catalogue saves are disabled' );
		wp_send_json_error(
			array(
				'code'    => 'legacy_schema_save_disabled',
				'message' => __( 'Legacy schema catalogue saving is disabled. Use Schema settings to change automatic preferences, or add specialized schema on a post, block, or targeted template.', 'seovault-ai' ),
			),
			409
		);
	}

	/**
	 * Render a single schema by type with variable replacement.
	 *
	 * @param string         $type     Schema type.
	 * @param array          $fields   Field values.
	 * @param SchemaRenderer $renderer Renderer instance.
	 * @return array
	 */
	private static function render_schema( $type, $fields, $renderer ) {
		foreach ( $fields as &$value ) {
			$renderer->render( $value );
		}
		unset( $value );

		if ( isset( $fields['sameAs'] ) && is_array( $fields['sameAs'] ) ) {
			$fields['sameAs'] = array_values( array_filter( $fields['sameAs'] ) );
		}

		// Normalize DateTime fields to ISO 8601 after variable resolution.
		$fields = SchemaSanitizer::normalize_datetime_fields( $fields, $type );

		$fields['@type'] = $type;
		$schema          = array_merge( array( '@type' => $type ), $fields );

		return self::remove_empty( self::remove_meta_keys( $schema ) );
	}

	/**
	 * Validate show_on rules for a schema.
	 *
	 * @param array                           $schema Schema config.
	 * @param \WP_Post|\WP_Term|\WP_User|null $object Explicit render object.
	 * @return bool
	 */
	private static function validate_show_on( $schema, $object = null ) {
		$show_on = $schema['show_on'] ?? array();
		$rules   = $show_on['rules'] ?? array();

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

		foreach ( $rules as $rule ) {
			if ( 'basic-global' === $rule ) {
				return true;
			}
			if ( 'special-front' === $rule && ( is_front_page() || ( $object instanceof \WP_Post && (int) get_option( 'page_on_front' ) === $object->ID ) ) ) {
				return true;
			}
			if ( 'post|all' === $rule && ( is_singular() || $object instanceof \WP_Post ) ) {
				return true;
			}
			if ( str_starts_with( $rule, 'post|' ) ) {
				$post_type = substr( $rule, 5 );
				if ( is_singular( $post_type ) || ( $object instanceof \WP_Post && $post_type === $object->post_type ) ) {
					return true;
				}
			}
			if ( str_starts_with( $rule, 'tax|' ) ) {
				$taxonomy = substr( $rule, 4 );
				if ( is_tax( $taxonomy ) || is_category() || is_tag() || ( $object instanceof \WP_Term && $taxonomy === $object->taxonomy ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Check not_show_on rules.
	 *
	 * @param array                           $schema Schema config.
	 * @param \WP_Post|\WP_Term|\WP_User|null $object Explicit render object.
	 * @return bool True if schema should be hidden.
	 */
	private static function check_not_show_on( $schema, $object = null ) {
		$not_show_on = $schema['not_show_on'] ?? array();
		$rules       = $not_show_on['rules'] ?? array();

		foreach ( $rules as $rule ) {
			if ( 'special-front' === $rule && ( is_front_page() || ( $object instanceof \WP_Post && (int) get_option( 'page_on_front' ) === $object->ID ) ) ) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Remove empty values recursively.
	 *
	 * @param array $data Data array.
	 * @return array
	 */
	private static function remove_empty( array $data ) {
		foreach ( $data as $key => $value ) {
			if ( is_array( $value ) ) {
				$data[ $key ] = self::remove_empty( $value );
				if ( empty( $data[ $key ] ) ) {
					unset( $data[ $key ] );
				}
			} elseif ( empty( $value ) && 0 !== $value && '0' !== $value ) {
				unset( $data[ $key ] );
			}
		}
		return $data;
	}

	/**
	 * Remove internal meta keys from schema output.
	 *
	 * @param array $schema Schema array.
	 * @return array
	 */
	private static function remove_meta_keys( array $schema ) {
		unset( $schema['schema_name'] );
		unset( $schema['schemaDocs'] );
		unset( $schema['googleDocs'] );
		unset( $schema['_customFields'] );
		return $schema;
	}

	/**
	 * Check if array is a sequential list.
	 *
	 * @param array $array Array to check.
	 * @return bool
	 */
	private static function is_list_array( array $array ) {
		if ( array() === $array ) {
			return true;
		}
		return array_keys( $array ) === range( 0, count( $array ) - 1 );
	}
}
