<?php
/**
 * Compare expected and observed schema graphs.
 *
 * @package SEOvaultSEO\Schema\Validation
 */

namespace SEOvaultSEO\Schema\Validation;

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

/**
 * Order-insensitive graph comparison for live-page validation.
 */
class SchemaGraphComparator {

	/**
	 * Stable order-insensitive hash for a schema graph.
	 *
	 * Uses the same canonicalization as compare().
	 *
	 * @param array $graph Graph envelope, node list, or empty array.
	 * @return string sha256:… digest
	 */
	public function hash_graph( $graph ) {
		return $this->graph_hash( $this->extract_nodes( is_array( $graph ) ? $graph : array() ) );
	}

	/**
	 * Compare two schema graphs after normalization.
	 *
	 * @param array $expected Expected graph (typically build_output_graph()).
	 * @param array $observed Observed graph from public HTML.
	 * @return array{
	 *   expected_hash:string,
	 *   observed_hash:string,
	 *   matches:bool,
	 *   expected_only:array,
	 *   observed_only:array,
	 *   changed:array
	 * }
	 */
	public function compare( $expected, $observed ) {
		$expected_nodes = $this->extract_nodes( $expected );
		$observed_nodes = $this->extract_nodes( $observed );

		$expected_map = $this->index_nodes( $expected_nodes );
		$observed_map = $this->index_nodes( $observed_nodes );

		$expected_only = array();
		$observed_only = array();
		$changed       = array();

		foreach ( $expected_map as $key => $node ) {
			if ( ! array_key_exists( $key, $observed_map ) ) {
				$expected_only[] = $this->summarize_node( $node, $key );
				continue;
			}
			if ( $this->node_hash( $node ) !== $this->node_hash( $observed_map[ $key ] ) ) {
				$changed[] = array(
					'key'      => $key,
					'expected' => $this->summarize_node( $node, $key ),
					'observed' => $this->summarize_node( $observed_map[ $key ], $key ),
				);
			}
		}

		foreach ( $observed_map as $key => $node ) {
			if ( ! array_key_exists( $key, $expected_map ) ) {
				$observed_only[] = $this->summarize_node( $node, $key );
			}
		}

		$expected_hash = $this->graph_hash( $expected_nodes );
		$observed_hash = $this->graph_hash( $observed_nodes );

		return array(
			'expected_hash' => $expected_hash,
			'observed_hash' => $observed_hash,
			'matches'       => $expected_hash === $observed_hash
				&& empty( $expected_only )
				&& empty( $observed_only )
				&& empty( $changed ),
			'expected_only' => $expected_only,
			'observed_only' => $observed_only,
			'changed'       => $changed,
		);
	}

	/**
	 * @param mixed $graph Graph or node list.
	 * @return array<int, array>
	 */
	private function extract_nodes( $graph ) {
		if ( ! is_array( $graph ) ) {
			return array();
		}

		if ( array_key_exists( '@graph', $graph ) ) {
			$nodes = $graph['@graph'];
			if ( ! is_array( $nodes ) ) {
				return array();
			}
			return ValueInspector::is_list( $nodes ) ? array_values( array_filter( $nodes, 'is_array' ) ) : array( $nodes );
		}

		if ( ValueInspector::is_list( $graph ) ) {
			return array_values( array_filter( $graph, 'is_array' ) );
		}

		return array( $graph );
	}

	/**
	 * Index nodes by @id or type+fingerprint.
	 *
	 * @param array<int, array> $nodes Nodes.
	 * @return array<string, array>
	 */
	private function index_nodes( $nodes ) {
		$indexed = array();
		foreach ( $nodes as $offset => $node ) {
			if ( ! is_array( $node ) ) {
				continue;
			}
			$key = $this->node_key( $node, $offset );
			// Prefer first occurrence for stable expected_only/observed_only.
			if ( ! array_key_exists( $key, $indexed ) ) {
				$indexed[ $key ] = $node;
			} else {
				$indexed[ $key . '#' . $offset ] = $node;
			}
		}

		return $indexed;
	}

	/**
	 * @param array $node   Node.
	 * @param int   $offset Fallback offset.
	 * @return string
	 */
	private function node_key( $node, $offset ) {
		$id = ValueInspector::get_id( $node );
		if ( '' !== $id ) {
			return 'id:' . $id;
		}

		$types = ValueInspector::get_types( $node );
		sort( $types );
		$type_key = ! empty( $types ) ? implode( ',', $types ) : 'Thing';

		return 'type:' . $type_key . ':' . $this->node_hash( $node );
	}

	/**
	 * @param array $nodes Nodes.
	 * @return string
	 */
	private function graph_hash( $nodes ) {
		$hashes = array();
		foreach ( $nodes as $node ) {
			$hashes[] = $this->node_hash( $node );
		}
		sort( $hashes, SORT_STRING );

		return 'sha256:' . hash( 'sha256', implode( '|', $hashes ) );
	}

	/**
	 * @param array $node Node.
	 * @return string
	 */
	private function node_hash( $node ) {
		$canonical = $this->canonicalize( $node );
		$encoded   = wp_json_encode( $canonical );
		if ( false === $encoded ) {
			$encoded = serialize( $canonical ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Hash input only.
		}

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

	/**
	 * @param array  $node Node.
	 * @param string $key  Index key.
	 * @return array{key:string,types:array,id:string}
	 */
	private function summarize_node( $node, $key ) {
		return array(
			'key'   => $key,
			'types' => ValueInspector::get_types( $node ),
			'id'    => ValueInspector::get_id( $node ),
		);
	}

	/**
	 * Recursively sort associative keys for stable comparison.
	 *
	 * @param mixed $value Value.
	 * @return mixed
	 */
	private function canonicalize( $value ) {
		if ( ! is_array( $value ) ) {
			return $value;
		}

		if ( ValueInspector::is_list( $value ) ) {
			$items = array();
			foreach ( $value as $item ) {
				$items[] = $this->canonicalize( $item );
			}
			// Sort list of objects by their encoded form for order-insensitive graphs.
			$all_arrays = true;
			foreach ( $items as $item ) {
				if ( ! is_array( $item ) ) {
					$all_arrays = false;
					break;
				}
			}
			if ( $all_arrays ) {
				usort(
					$items,
					function ( $left, $right ) {
						$left_json  = wp_json_encode( $left );
						$right_json = wp_json_encode( $right );
						return strcmp( (string) $left_json, (string) $right_json );
					}
				);
			}

			return $items;
		}

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

		return $value;
	}
}
