<?php
/**
 * Canonical schema build result value object.
 *
 * Carries the public JSON-LD graph plus internal diagnostics metadata that
 * must never be printed inside JSON-LD.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

use SEOvaultSEO\Schema\Validation\SchemaGraphComparator;
use SEOvaultSEO\Schema\Validation\ValueInspector;

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

/**
 * Immutable result boundary for SchemaManager::build_result().
 */
class SchemaBuildResult {

	/**
	 * Allowed source kinds for emitted descriptors in this phase.
	 *
	 * @var string[]
	 */
	const SOURCE_KINDS = array(
		'foundational',
		'post_instance',
		'block',
		'template',
		'integration',
		'custom',
		'extension',
	);

	/**
	 * Public JSON-LD graph envelope or empty array.
	 *
	 * @var array
	 */
	private $graph;

	/**
	 * Emitted entity descriptors (diagnostics only).
	 *
	 * @var array<int,array>
	 */
	private $emitted;

	/**
	 * Withheld instance descriptors (diagnostics only).
	 *
	 * @var array<int,array>
	 */
	private $withheld;

	/**
	 * Improvement diagnostics (not blockers).
	 *
	 * @var array<int,array>
	 */
	private $improvements;

	/**
	 * Conflict diagnostics.
	 *
	 * @var array<int,array>
	 */
	private $conflicts;

	/**
	 * Source map keyed by entity @id or stable descriptor key.
	 *
	 * @var array<string,array>
	 */
	private $source_map;

	/**
	 * Stable canonical graph hash.
	 *
	 * @var string
	 */
	private $graph_hash;

	/**
	 * Create an immutable build result.
	 *
	 * @param array  $graph         Public graph.
	 * @param array  $emitted       Emitted descriptors.
	 * @param array  $withheld      Withheld descriptors.
	 * @param array  $improvements  Improvements.
	 * @param array  $conflicts     Conflicts.
	 * @param array  $source_map    Source map.
	 * @param string $graph_hash    Precomputed hash (optional).
	 */
	public function __construct(
		array $graph = array(),
		array $emitted = array(),
		array $withheld = array(),
		array $improvements = array(),
		array $conflicts = array(),
		array $source_map = array(),
		$graph_hash = ''
	) {
		$this->graph        = self::sanitize_graph( $graph );
		$this->emitted      = self::sanitize_descriptor_list( $emitted );
		$this->withheld     = self::sanitize_descriptor_list( $withheld );
		$this->improvements = self::sanitize_plain_list( $improvements );
		$this->conflicts    = self::sanitize_plain_list( $conflicts );
		$this->source_map   = self::sanitize_source_map(
			! empty( $source_map ) ? $source_map : self::build_source_map( $this->emitted )
		);
		$this->graph_hash   = is_string( $graph_hash ) && '' !== $graph_hash
			? $graph_hash
			: self::hash_graph( $this->graph );
	}

	/**
	 * Empty result (no publishable graph).
	 *
	 * @return self
	 */
	public static function empty_result() {
		return new self( array(), array(), array(), array(), array(), array(), self::hash_graph( array() ) );
	}

	/**
	 * Build a result from a public graph and optional emitted descriptors.
	 *
	 * @param array $graph   Public JSON-LD graph or empty array.
	 * @param array $emitted Emitted descriptors.
	 * @return self
	 */
	public static function from_graph( array $graph, array $emitted = array() ) {
		$graph   = self::sanitize_graph( $graph );
		$emitted = self::sanitize_descriptor_list( $emitted );
		if ( empty( $emitted ) && ! empty( $graph['@graph'] ) && is_array( $graph['@graph'] ) ) {
			$emitted = self::descriptors_from_nodes( $graph['@graph'], '' );
		}

		return new self(
			$graph,
			$emitted,
			array(),
			array(),
			array(),
			self::build_source_map( $emitted ),
			self::hash_graph( $graph )
		);
	}

	/**
	 * Stable hash for a graph using SchemaGraphComparator canonicalization.
	 *
	 * @param array $graph Graph or empty array.
	 * @return string
	 */
	public static function hash_graph( array $graph ) {
		$comparator = new SchemaGraphComparator();
		return $comparator->hash_graph( $graph );
	}

	/**
	 * Build emitted descriptors from graph nodes with an optional known source.
	 *
	 * @param array  $nodes  Graph nodes.
	 * @param string $source Known source kind or empty when unknown.
	 * @return array<int,array>
	 */
	public static function descriptors_from_nodes( array $nodes, $source = '' ) {
		$descriptors = array();
		$index       = 0;
		foreach ( $nodes as $node ) {
			if ( ! is_array( $node ) || empty( $node ) ) {
				continue;
			}
			$descriptors[] = self::descriptor_from_node( $node, $source, $index );
			++$index;
		}
		return $descriptors;
	}

	/**
	 * Build one emitted descriptor from a public node.
	 *
	 * @param array  $node       Public node (no internal metadata).
	 * @param string $source     Known source kind or empty.
	 * @param int    $index      Graph index.
	 * @param string $source_ref Optional stable source reference.
	 * @return array
	 */
	public static function descriptor_from_node( array $node, $source = '', $index = 0, $source_ref = '' ) {
		$types = ValueInspector::get_types( $node );
		$type  = ! empty( $types ) ? (string) $types[0] : 'Thing';
		$id    = ValueInspector::get_id( $node );

		$descriptor = array(
			'type'        => $type,
			'entity_id'   => $id,
			'graph_index' => (int) $index,
			'emitted'     => true,
			'withheld'    => false,
			'source'      => '',
			'source_id'   => '',
			'source_ref'  => '',
			'diagnostics' => array(),
		);

		$source = self::sanitize_source_kind( $source );
		if ( '' !== $source ) {
			$descriptor['source']    = $source;
			$seed                    = ( '' !== $id ? $id : $type ) . '|' . $source . '|' . (int) $index;
			$descriptor['source_id'] = 'src-' . substr( md5( $seed ), 0, 12 );
		}

		$source_ref = is_string( $source_ref ) ? sanitize_text_field( $source_ref ) : '';
		if ( '' !== $source_ref ) {
			$descriptor['source_ref'] = $source_ref;
		}

		return $descriptor;
	}

	/**
	 * Public JSON-LD graph (never includes source metadata).
	 *
	 * @return array
	 */
	public function get_graph() {
		return self::deep_copy( $this->graph );
	}

	/**
	 * Emitted entity descriptors.
	 *
	 * @return array<int,array>
	 */
	public function get_emitted() {
		return self::deep_copy( $this->emitted );
	}

	/**
	 * Withheld instance descriptors.
	 *
	 * @return array<int,array>
	 */
	public function get_withheld() {
		return self::deep_copy( $this->withheld );
	}

	/**
	 * Improvement diagnostics.
	 *
	 * @return array<int,array>
	 */
	public function get_improvements() {
		return self::deep_copy( $this->improvements );
	}

	/**
	 * Conflict diagnostics.
	 *
	 * @return array<int,array>
	 */
	public function get_conflicts() {
		return self::deep_copy( $this->conflicts );
	}

	/**
	 * Source map keyed by entity id / source id.
	 *
	 * @return array<string,array>
	 */
	public function get_source_map() {
		return self::deep_copy( $this->source_map );
	}

	/**
	 * Stable graph hash.
	 *
	 * @return string
	 */
	public function get_graph_hash() {
		return $this->graph_hash;
	}

	/**
	 * Safe diagnostic serialization (not JSON-LD).
	 *
	 * @return array
	 */
	public function to_array() {
		return array(
			'graph'        => $this->get_graph(),
			'emitted'      => $this->get_emitted(),
			'withheld'     => $this->get_withheld(),
			'improvements' => $this->get_improvements(),
			'conflicts'    => $this->get_conflicts(),
			'source_map'   => $this->get_source_map(),
			'graph_hash'   => $this->graph_hash,
		);
	}

	/**
	 * Sanitize a public graph envelope and strip internal diagnostic keys.
	 *
	 * @param array $graph Candidate graph.
	 * @return array
	 */
	private static function sanitize_graph( array $graph ) {
		if ( empty( $graph ) ) {
			return array();
		}

		if ( ! isset( $graph['@graph'] ) || ! is_array( $graph['@graph'] ) ) {
			return array();
		}

		$nodes = array();
		foreach ( $graph['@graph'] as $node ) {
			if ( ! is_array( $node ) || empty( $node ) ) {
				continue;
			}
			// Strip any accidental internal diagnostic keys from public nodes.
			unset( $node['_seovault_source'], $node['_seovault_source_id'], $node['_seovault_diagnostics'] );
			$nodes[] = $node;
		}

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

		$context = isset( $graph['@context'] ) ? $graph['@context'] : 'https://schema.org';

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

	/**
	 * Sanitize a list of emitted/withheld descriptors.
	 *
	 * @param array $descriptors Descriptor list.
	 * @return array<int,array>
	 */
	private static function sanitize_descriptor_list( array $descriptors ) {
		$out = array();
		foreach ( $descriptors as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$out[] = self::sanitize_descriptor( $item );
		}
		return $out;
	}

	/**
	 * Sanitize one descriptor for diagnostics storage.
	 *
	 * @param array $descriptor Raw descriptor.
	 * @return array
	 */
	private static function sanitize_descriptor( array $descriptor ) {
		$type = isset( $descriptor['type'] ) ? sanitize_text_field( (string) $descriptor['type'] ) : 'Thing';
		$id   = '';
		if ( isset( $descriptor['entity_id'] ) && is_string( $descriptor['entity_id'] ) ) {
			// @id values are URIs; preserve fragments without forcing http-only URL rules.
			$id = esc_url_raw( $descriptor['entity_id'], array( 'http', 'https' ) );
			if ( '' === $id ) {
				$id = sanitize_text_field( $descriptor['entity_id'] );
			}
		}

		$source    = self::sanitize_source_kind( isset( $descriptor['source'] ) ? $descriptor['source'] : '' );
		$source_id = isset( $descriptor['source_id'] ) ? sanitize_key( (string) $descriptor['source_id'] ) : '';

		$status = isset( $descriptor['status'] ) ? sanitize_key( (string) $descriptor['status'] ) : '';
		if ( ! in_array( $status, SchemaSourceDescriptor::STATUSES, true ) ) {
			$status = '';
		}

		$payload_hash = '';
		if ( isset( $descriptor['payload_hash'] ) && is_string( $descriptor['payload_hash'] ) ) {
			$payload_hash = sanitize_text_field( $descriptor['payload_hash'] );
		}

		$out = array(
			'type'             => '' !== $type ? $type : 'Thing',
			'entity_id'        => $id,
			'graph_index'      => isset( $descriptor['graph_index'] ) ? (int) $descriptor['graph_index'] : 0,
			'emitted'          => ! empty( $descriptor['emitted'] ),
			'withheld'         => ! empty( $descriptor['withheld'] ),
			'source'           => $source,
			'source_id'        => $source_id,
			'source_ref'       => isset( $descriptor['source_ref'] ) ? sanitize_text_field( (string) $descriptor['source_ref'] ) : '',
			'status'           => $status,
			'payload_hash'     => $payload_hash,
			'precedence'       => isset( $descriptor['precedence'] ) ? (int) $descriptor['precedence'] : 0,
			'reason'           => isset( $descriptor['reason'] ) ? sanitize_key( (string) $descriptor['reason'] ) : '',
			'rejection_reason' => isset( $descriptor['rejection_reason'] ) ? sanitize_text_field( (string) $descriptor['rejection_reason'] ) : '',
			'rejection_code'   => isset( $descriptor['rejection_code'] ) ? sanitize_key( (string) $descriptor['rejection_code'] ) : '',
			'diagnostics'      => self::sanitize_plain_list(
				isset( $descriptor['diagnostics'] ) && is_array( $descriptor['diagnostics'] )
					? $descriptor['diagnostics']
					: array()
			),
			'setup'            => self::sanitize_plain_list(
				isset( $descriptor['setup'] ) && is_array( $descriptor['setup'] )
					? $descriptor['setup']
					: array()
			),
		);

		if ( isset( $descriptor['descriptor_id'] ) && is_string( $descriptor['descriptor_id'] ) ) {
			$out['descriptor_id'] = sanitize_key( $descriptor['descriptor_id'] );
		}

		return $out;
	}

	/**
	 * Deep-copy arrays so callers cannot mutate internal state.
	 *
	 * @param array $value Value.
	 * @return array
	 */
	private static function deep_copy( array $value ) {
		$encoded = wp_json_encode( $value );
		if ( false === $encoded ) {
			return $value;
		}
		$decoded = json_decode( $encoded, true );
		return is_array( $decoded ) ? $decoded : array();
	}

	/**
	 * Sanitize a source kind against the allow-list.
	 *
	 * @param mixed $source Source kind.
	 * @return string
	 */
	private static function sanitize_source_kind( $source ) {
		$source = is_string( $source ) ? $source : '';
		if ( ! in_array( $source, self::SOURCE_KINDS, true ) ) {
			return '';
		}
		return $source;
	}

	/**
	 * Keep only array items from a plain diagnostic list.
	 *
	 * @param array $items Plain list of arrays.
	 * @return array<int,array>
	 */
	private static function sanitize_plain_list( array $items ) {
		$out = array();
		foreach ( $items as $item ) {
			if ( is_array( $item ) ) {
				$out[] = $item;
			}
		}
		return $out;
	}

	/**
	 * Sanitize a source map keyed by entity/source id.
	 *
	 * @param array $map Source map.
	 * @return array<string,array>
	 */
	private static function sanitize_source_map( array $map ) {
		$out = array();
		foreach ( $map as $key => $descriptor ) {
			if ( ! is_string( $key ) || '' === $key || ! is_array( $descriptor ) ) {
				continue;
			}
			$out[ sanitize_text_field( $key ) ] = self::sanitize_descriptor( $descriptor );
		}
		return $out;
	}

	/**
	 * Build a source map from emitted descriptors.
	 *
	 * @param array $emitted Emitted descriptors.
	 * @return array<string,array>
	 */
	private static function build_source_map( array $emitted ) {
		$map = array();
		foreach ( $emitted as $descriptor ) {
			if ( ! is_array( $descriptor ) ) {
				continue;
			}
			$key = '';
			if ( ! empty( $descriptor['entity_id'] ) && is_string( $descriptor['entity_id'] ) ) {
				$key = $descriptor['entity_id'];
			} elseif ( ! empty( $descriptor['source_id'] ) && is_string( $descriptor['source_id'] ) ) {
				$key = $descriptor['source_id'];
			} else {
				$key = 'index:' . (int) ( $descriptor['graph_index'] ?? 0 );
			}
			if ( ! isset( $map[ $key ] ) ) {
				$map[ $key ] = $descriptor;
			}
		}
		return $map;
	}
}
