<?php
/**
 * Normalized schema source descriptor helpers (Architecture V2).
 *
 * Descriptors describe candidate contributions for diagnostics and conflict
 * detection. They are never printed inside public JSON-LD.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

use SEOvaultSEO\Schema\Readiness\SchemaReadinessValues;
use SEOvaultSEO\Schema\Validation\ValueInspector;

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

/**
 * Factory/normalizer for V2 schema source descriptors.
 */
class SchemaSourceDescriptor {

	const STATUS_CANDIDATE = 'candidate';
	const STATUS_DUPLICATE = 'duplicate';
	const STATUS_CONFLICT  = 'conflict';
	const STATUS_REJECTED  = 'rejected';

	/**
	 * Allowed descriptor statuses.
	 *
	 * @var string[]
	 */
	const STATUSES = array(
		self::STATUS_CANDIDATE,
		self::STATUS_DUPLICATE,
		self::STATUS_CONFLICT,
		self::STATUS_REJECTED,
	);

	/**
	 * Source precedence (higher wins on same @id conflicts).
	 *
	 * @var array<string,int>
	 */
	const PRECEDENCE = array(
		'custom'        => 100,
		'post_instance' => 90,
		'block'         => 80,
		'integration'   => 70,
		'template'      => 50,
		'extension'     => 40,
		'foundational'  => 30,
	);

	/**
	 * Normalize a raw contribution into zero or more descriptors.
	 *
	 * Malformed contributions yield a single rejected descriptor.
	 *
	 * @param mixed $raw Raw contribution (array expected).
	 * @return array<int,array>
	 */
	public static function from_contribution( $raw ) {
		if ( ! is_array( $raw ) ) {
			return array(
				self::rejected(
					'',
					'',
					'',
					'malformed_contribution',
					'Contribution must be an array.'
				),
			);
		}

		$source = self::sanitize_source_kind( isset( $raw['source'] ) ? $raw['source'] : '' );
		if ( '' === $source ) {
			return array(
				self::rejected(
					'',
					isset( $raw['source_ref'] ) ? (string) $raw['source_ref'] : '',
					'',
					'unknown_source',
					'Contribution source kind is missing or not allow-listed.'
				),
			);
		}

		$source_ref = self::normalize_source_ref( isset( $raw['source_ref'] ) ? $raw['source_ref'] : '' );

		// Explicit node payload.
		if ( isset( $raw['node'] ) && is_array( $raw['node'] ) ) {
			return array( self::from_node( $raw['node'], $source, $source_ref, $raw ) );
		}

		// Explicit fields + type.
		if ( isset( $raw['fields'] ) && is_array( $raw['fields'] ) ) {
			$type = self::normalize_type(
				isset( $raw['type'] ) ? $raw['type'] : ( $raw['fields']['@type'] ?? '' )
			);
			return array( self::from_fields( $type, $raw['fields'], $source, $source_ref, $raw ) );
		}

		// Schema config shape used by SchemaManager (type/fields or raw graph).
		if ( isset( $raw['schema'] ) && is_array( $raw['schema'] ) ) {
			return self::from_schema_payload( $raw['schema'], $source, $source_ref, $raw );
		}

		// Already a JSON-LD node or graph envelope.
		if ( isset( $raw['@graph'] ) || isset( $raw['@type'] ) || isset( $raw['@id'] ) ) {
			return self::from_schema_payload( $raw, $source, $source_ref, $raw );
		}

		return array(
			self::rejected(
				$source,
				$source_ref,
				'',
				'unrecognized_shape',
				'Contribution did not include node, fields, schema, or JSON-LD entity data.'
			),
		);
	}

	/**
	 * Build a descriptor from a public JSON-LD node.
	 *
	 * @param array  $node       Node.
	 * @param string $source     Source kind.
	 * @param string $source_ref Source reference.
	 * @param array  $meta       Extra contribution metadata.
	 * @return array
	 */
	public static function from_node( array $node, $source, $source_ref = '', array $meta = array() ) {
		$source = self::sanitize_source_kind( $source );
		if ( '' === $source ) {
			return self::rejected( '', $source_ref, '', 'unknown_source', 'Unknown source kind.' );
		}

		if ( empty( $node ) || SchemaReadinessValues::is_oversized( $node ) ) {
			return self::rejected(
				$source,
				$source_ref,
				'',
				'oversized_or_empty',
				'Node payload is empty or oversized.'
			);
		}

		$types = ValueInspector::get_types( $node );
		$type  = ! empty( $types ) ? self::normalize_type( $types[0] ) : self::normalize_type( isset( $meta['type'] ) ? $meta['type'] : '' );
		if ( '' === $type ) {
			return self::rejected(
				$source,
				$source_ref,
				'',
				'missing_type',
				'Node is missing a usable @type.'
			);
		}

		$fields = self::normalize_fields( $node );
		if ( 'FAQPage' === $type ) {
			$fields = self::dedupe_faq_fields( $fields );
		}

		$entity_id = self::normalize_entity_id(
			isset( $meta['entity_id'] ) ? $meta['entity_id'] : ValueInspector::get_id( $fields )
		);

		return self::candidate( $type, $entity_id, $fields, $source, $source_ref, $meta );
	}

	/**
	 * Build a descriptor from type + fields.
	 *
	 * @param string $type       Schema type.
	 * @param array  $fields     Fields.
	 * @param string $source     Source kind.
	 * @param string $source_ref Source reference.
	 * @param array  $meta       Extra metadata.
	 * @return array
	 */
	public static function from_fields( $type, array $fields, $source, $source_ref = '', array $meta = array() ) {
		$source = self::sanitize_source_kind( $source );
		$type   = self::normalize_type( $type );
		if ( '' === $source ) {
			return self::rejected( '', $source_ref, $type, 'unknown_source', 'Unknown source kind.' );
		}
		if ( '' === $type ) {
			return self::rejected( $source, $source_ref, '', 'missing_type', 'Missing schema type.' );
		}
		if ( empty( $fields ) || SchemaReadinessValues::is_oversized( $fields ) ) {
			return self::rejected(
				$source,
				$source_ref,
				$type,
				'oversized_or_empty',
				'Fields payload is empty or oversized.'
			);
		}

		$fields = self::normalize_fields( $fields );
		if ( ! isset( $fields['@type'] ) ) {
			$fields['@type'] = $type;
		}
		if ( 'FAQPage' === $type ) {
			$fields = self::dedupe_faq_fields( $fields );
		}

		$entity_id = self::normalize_entity_id(
			isset( $meta['entity_id'] ) ? $meta['entity_id'] : ValueInspector::get_id( $fields )
		);

		return self::candidate( $type, $entity_id, $fields, $source, $source_ref, $meta );
	}

	/**
	 * Expand a schema config or raw graph into descriptors.
	 *
	 * @param array  $schema     Schema config or JSON-LD.
	 * @param string $source     Source kind.
	 * @param string $source_ref Source reference.
	 * @param array  $meta       Extra metadata.
	 * @return array<int,array>
	 */
	public static function from_schema_payload( array $schema, $source, $source_ref = '', array $meta = array() ) {
		if ( isset( $schema['@graph'] ) ) {
			if ( ! is_array( $schema['@graph'] ) ) {
				return array(
					self::rejected( $source, $source_ref, '', 'malformed_graph', '@graph must be an array.' ),
				);
			}
			$out = array();
			foreach ( $schema['@graph'] as $index => $node ) {
				if ( ! is_array( $node ) ) {
					$out[] = self::rejected(
						$source,
						'' !== $source_ref ? $source_ref : 'graph:' . (int) $index,
						'',
						'malformed_node',
						'Graph entry is not an object.'
					);
					continue;
				}
				$out[] = self::from_node(
					$node,
					$source,
					'' !== $source_ref ? $source_ref : 'graph:' . (int) $index,
					$meta
				);
			}
			return $out;
		}

		if ( isset( $schema['@type'] ) || ( isset( $schema['@id'] ) && ! isset( $schema['fields'] ) && ! isset( $schema['type'] ) ) ) {
			return array( self::from_node( $schema, $source, $source_ref, $meta ) );
		}

		$type   = self::normalize_type( isset( $schema['type'] ) ? $schema['type'] : ( $schema['fields']['@type'] ?? '' ) );
		$fields = array();
		if ( isset( $schema['fields'] ) && is_array( $schema['fields'] ) ) {
			$fields = $schema['fields'];
		} elseif ( ! empty( $schema ) && ! isset( $schema['enabled'] ) && ! isset( $schema['show_on'] ) ) {
			// Treat leftover associative payload as fields when it looks entity-like.
			$fields = $schema;
		}

		if ( '' === $type && isset( $fields['@type'] ) ) {
			$type = self::normalize_type( $fields['@type'] );
		}

		$ref = $source_ref;
		if ( '' === $ref && isset( $schema['template_id'] ) ) {
			$ref = 'template:' . (int) $schema['template_id'];
		}

		return array( self::from_fields( $type, is_array( $fields ) ? $fields : array(), $source, $ref, $meta ) );
	}

	/**
	 * Candidate descriptor.
	 *
	 * @param string $type       Type.
	 * @param string $entity_id  Entity @id.
	 * @param array  $fields     Normalized fields.
	 * @param string $source     Source kind.
	 * @param string $source_ref Source reference.
	 * @param array  $meta       Extra metadata.
	 * @return array
	 */
	public static function candidate( $type, $entity_id, array $fields, $source, $source_ref = '', array $meta = array() ) {
		$source_id = self::stable_source_id( $source, $source_ref, $type, $entity_id );

		return array(
			'descriptor_id'    => $source_id,
			'type'             => $type,
			'entity_id'        => $entity_id,
			'source'           => $source,
			'source_id'        => $source_id,
			'source_ref'       => self::normalize_source_ref( $source_ref ),
			'fields'           => $fields,
			'payload_hash'     => self::payload_hash( $fields ),
			'status'           => self::STATUS_CANDIDATE,
			'precedence'       => self::precedence_for( $source ),
			'rejection_reason' => '',
			'rejection_code'   => '',
			'emitted'          => false,
			'withheld'         => false,
			'diagnostics'      => self::sanitize_diagnostics( isset( $meta['diagnostics'] ) ? $meta['diagnostics'] : array() ),
		);
	}

	/**
	 * Rejected descriptor.
	 *
	 * @param string $source     Source kind (may be empty).
	 * @param string $source_ref Source reference.
	 * @param string $type       Type if known.
	 * @param string $code       Rejection code.
	 * @param string $reason     Human-readable reason.
	 * @return array
	 */
	public static function rejected( $source, $source_ref, $type, $code, $reason ) {
		$source    = self::sanitize_source_kind( $source );
		$type      = self::normalize_type( $type );
		$source_id = self::stable_source_id( '' !== $source ? $source : 'rejected', $source_ref, $type, $code );

		return array(
			'descriptor_id'    => $source_id,
			'type'             => '' !== $type ? $type : 'Thing',
			'entity_id'        => '',
			'source'           => $source,
			'source_id'        => $source_id,
			'source_ref'       => self::normalize_source_ref( $source_ref ),
			'fields'           => array(),
			'payload_hash'     => '',
			'status'           => self::STATUS_REJECTED,
			'precedence'       => self::precedence_for( $source ),
			'rejection_reason' => sanitize_text_field( (string) $reason ),
			'rejection_code'   => sanitize_key( (string) $code ),
			'emitted'          => false,
			'withheld'         => false,
			'diagnostics'      => array(
				array(
					'code'   => sanitize_key( (string) $code ),
					'reason' => sanitize_text_field( (string) $reason ),
				),
			),
		);
	}

	/**
	 * Normalize entity @id.
	 *
	 * @param mixed $id Raw id.
	 * @return string
	 */
	public static function normalize_entity_id( $id ) {
		if ( ! is_string( $id ) ) {
			return '';
		}
		$id = trim( $id );
		if ( '' === $id ) {
			return '';
		}

		$safe = esc_url_raw( $id, array( 'http', 'https' ) );
		if ( '' !== $safe ) {
			return $safe;
		}

		// Allow fragment-style ids used in fixtures (e.g. "#event").
		return sanitize_text_field( $id );
	}

	/**
	 * Normalize a schema type name.
	 *
	 * @param mixed $type Raw type.
	 * @return string
	 */
	public static function normalize_type( $type ) {
		if ( is_array( $type ) ) {
			$type = reset( $type );
		}
		if ( ! is_string( $type ) ) {
			return '';
		}
		$type = trim( $type );
		if ( '' === $type ) {
			return '';
		}
		// Keep Schema.org CamelCase; strip unsafe characters.
		$type = preg_replace( '/[^A-Za-z0-9._-]/', '', $type );
		return is_string( $type ) ? $type : '';
	}

	/**
	 * Normalize fields for hashing/comparison.
	 *
	 * @param array $fields Raw fields/node.
	 * @return array
	 */
	public static function normalize_fields( array $fields ) {
		unset( $fields['_seovault_source'], $fields['_seovault_source_id'], $fields['_seovault_diagnostics'] );
		return self::canonicalize( $fields );
	}

	/**
	 * Stable payload hash.
	 *
	 * @param array $fields Normalized fields.
	 * @return string
	 */
	public static function payload_hash( array $fields ) {
		$encoded = wp_json_encode( self::canonicalize( $fields ) );
		if ( false === $encoded ) {
			$encoded = serialize( $fields ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Hash input only.
		}
		return 'sha256:' . hash( 'sha256', $encoded );
	}

	/**
	 * Normalize FAQ question text for dedupe.
	 *
	 * @param mixed $text Question text.
	 * @return string
	 */
	public static function normalize_faq_question( $text ) {
		if ( ! is_string( $text ) ) {
			return '';
		}
		$text = wp_strip_all_tags( $text );
		$text = html_entity_decode( $text, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$text = strtolower( $text );
		$text = preg_replace( '/\s+/u', ' ', $text );
		return is_string( $text ) ? trim( $text ) : '';
	}

	/**
	 * Deduplicate FAQPage mainEntity questions by normalized question text.
	 *
	 * @param array $fields FAQ fields.
	 * @return array
	 */
	public static function dedupe_faq_fields( array $fields ) {
		if ( empty( $fields['mainEntity'] ) || ! is_array( $fields['mainEntity'] ) ) {
			return $fields;
		}

		$items = ValueInspector::is_list( $fields['mainEntity'] )
			? $fields['mainEntity']
			: array( $fields['mainEntity'] );

		$seen = array();
		$keep = array();
		foreach ( $items as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$question = '';
			if ( isset( $item['name'] ) && is_string( $item['name'] ) ) {
				$question = $item['name'];
			} elseif ( isset( $item['question'] ) && is_string( $item['question'] ) ) {
				$question = $item['question'];
			}
			$key = self::normalize_faq_question( $question );
			if ( '' === $key ) {
				$keep[] = $item;
				continue;
			}
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$keep[]       = $item;
		}

		$fields['mainEntity'] = array_values( $keep );
		return $fields;
	}

	/**
	 * Precedence score for a source kind.
	 *
	 * @param string $source Source kind.
	 * @return int
	 */
	public static function precedence_for( $source ) {
		$source = self::sanitize_source_kind( $source );
		return isset( self::PRECEDENCE[ $source ] ) ? (int) self::PRECEDENCE[ $source ] : 0;
	}

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

	/**
	 * Normalize a source reference string.
	 *
	 * @param mixed $ref Reference.
	 * @return string
	 */
	public static function normalize_source_ref( $ref ) {
		if ( ! is_string( $ref ) && ! is_numeric( $ref ) ) {
			return '';
		}
		return sanitize_text_field( (string) $ref );
	}

	/**
	 * Diagnostic-safe copy without heavy fields payload.
	 *
	 * @param array $descriptor Full descriptor.
	 * @return array
	 */
	public static function to_diagnostic( array $descriptor ) {
		return array(
			'descriptor_id'    => isset( $descriptor['descriptor_id'] ) ? (string) $descriptor['descriptor_id'] : '',
			'type'             => isset( $descriptor['type'] ) ? (string) $descriptor['type'] : 'Thing',
			'entity_id'        => isset( $descriptor['entity_id'] ) ? (string) $descriptor['entity_id'] : '',
			'source'           => isset( $descriptor['source'] ) ? (string) $descriptor['source'] : '',
			'source_id'        => isset( $descriptor['source_id'] ) ? (string) $descriptor['source_id'] : '',
			'source_ref'       => isset( $descriptor['source_ref'] ) ? (string) $descriptor['source_ref'] : '',
			'payload_hash'     => isset( $descriptor['payload_hash'] ) ? (string) $descriptor['payload_hash'] : '',
			'status'           => isset( $descriptor['status'] ) ? (string) $descriptor['status'] : '',
			'precedence'       => isset( $descriptor['precedence'] ) ? (int) $descriptor['precedence'] : 0,
			'rejection_reason' => isset( $descriptor['rejection_reason'] ) ? (string) $descriptor['rejection_reason'] : '',
			'rejection_code'   => isset( $descriptor['rejection_code'] ) ? (string) $descriptor['rejection_code'] : '',
			'emitted'          => ! empty( $descriptor['emitted'] ),
			'withheld'         => ! empty( $descriptor['withheld'] ),
			'diagnostics'      => self::sanitize_diagnostics(
				isset( $descriptor['diagnostics'] ) && is_array( $descriptor['diagnostics'] )
					? $descriptor['diagnostics']
					: array()
			),
		);
	}

	/**
	 * Stable source id.
	 *
	 * @param string $source     Source.
	 * @param string $source_ref Ref.
	 * @param string $type       Type.
	 * @param string $entity_id  Entity id or code.
	 * @return string
	 */
	private static function stable_source_id( $source, $source_ref, $type, $entity_id ) {
		$seed = (string) $source . '|' . (string) $source_ref . '|' . (string) $type . '|' . (string) $entity_id;
		return 'src-' . substr( md5( $seed ), 0, 12 );
	}

	/**
	 * Keep only array diagnostic entries.
	 *
	 * @param mixed $diagnostics Diagnostics list.
	 * @return array<int,array>
	 */
	private static function sanitize_diagnostics( $diagnostics ) {
		if ( ! is_array( $diagnostics ) ) {
			return array();
		}
		$out = array();
		foreach ( $diagnostics as $item ) {
			if ( is_array( $item ) ) {
				$out[] = $item;
			}
		}
		return $out;
	}

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

		if ( ValueInspector::is_list( $value ) ) {
			$items = array();
			foreach ( $value as $item ) {
				$items[] = self::canonicalize( $item );
			}
			return $items;
		}

		ksort( $value, SORT_STRING );
		foreach ( $value as $key => $item ) {
			$value[ $key ] = self::canonicalize( $item );
		}
		return $value;
	}
}
