<?php
/**
 * Schema source collector and conflict detector (Architecture V2).
 *
 * Collects normalized contribution descriptors without trusting them for
 * emission. Detects duplicates and @id conflicts by payload hash and source
 * precedence. Does not deduplicate solely by @type.
 *
 * @package SEOvaultSEO\Schema
 */

namespace SEOvaultSEO\Schema;

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

/**
 * Resolves raw contributions into candidates, duplicates, conflicts, and rejects.
 */
class SchemaSourceCollector {

	/**
	 * Normalize and resolve an arbitrary list of raw contributions.
	 *
	 * @param array $contributions Raw contribution list.
	 * @return array{
	 *   candidates: array<int,array>,
	 *   rejected: array<int,array>,
	 *   duplicates: array<int,array>,
	 *   conflicts: array<int,array>,
	 *   source_map: array<string,array>,
	 *   descriptors: array<int,array>
	 * }
	 */
	public function resolve( array $contributions ) {
		$descriptors = array();
		$rejected    = array();

		foreach ( $contributions as $index => $raw ) {
			$batch = SchemaSourceDescriptor::from_contribution( $raw );
			foreach ( $batch as $descriptor ) {
				if ( ! is_array( $descriptor ) ) {
					continue;
				}
				if ( SchemaSourceDescriptor::STATUS_REJECTED === ( $descriptor['status'] ?? '' ) ) {
					if ( '' === ( $descriptor['source_ref'] ?? '' ) ) {
						$descriptor['source_ref'] = 'contribution:' . (int) $index;
					}
					$rejected[]    = $descriptor;
					$descriptors[] = $descriptor;
					continue;
				}
				$descriptors[] = $descriptor;
			}
		}

		return $this->resolve_descriptors( $descriptors, $rejected );
	}

	/**
	 * Resolve already-normalized descriptors (candidate/rejected).
	 *
	 * @param array $descriptors Descriptor list.
	 * @param array $rejected    Pre-collected rejected list (optional).
	 * @return array{
	 *   candidates: array<int,array>,
	 *   rejected: array<int,array>,
	 *   duplicates: array<int,array>,
	 *   conflicts: array<int,array>,
	 *   source_map: array<string,array>,
	 *   descriptors: array<int,array>
	 * }
	 */
	public function resolve_descriptors( array $descriptors, array $rejected = array() ) {
		$by_id        = array();
		$no_id        = array();
		$all          = array();
		$duplicates   = array();
		$conflicts    = array();
		$candidates   = array();
		$rejected_out = $rejected;

		foreach ( $descriptors as $descriptor ) {
			if ( ! is_array( $descriptor ) ) {
				continue;
			}
			if ( SchemaSourceDescriptor::STATUS_REJECTED === ( $descriptor['status'] ?? '' ) ) {
				if ( ! in_array( $descriptor, $rejected_out, true ) ) {
					$rejected_out[] = $descriptor;
				}
				$all[] = $descriptor;
				continue;
			}

			$entity_id = isset( $descriptor['entity_id'] ) ? (string) $descriptor['entity_id'] : '';
			if ( '' === $entity_id ) {
				// Different entities without @id are kept as independent candidates.
				// Same type alone is never treated as a duplicate/conflict key.
				$no_id[] = $descriptor;
				$all[]   = $descriptor;
				continue;
			}

			if ( ! isset( $by_id[ $entity_id ] ) ) {
				$by_id[ $entity_id ] = array();
			}
			$by_id[ $entity_id ][] = $descriptor;
		}

		foreach ( $by_id as $entity_id => $group ) {
			$resolved = $this->resolve_id_group( (string) $entity_id, $group );
			foreach ( $resolved['kept'] as $kept ) {
				$candidates[] = $kept;
				$all[]        = $kept;
			}
			foreach ( $resolved['duplicates'] as $dup ) {
				$duplicates[] = $dup;
				$all[]        = $dup;
			}
			foreach ( $resolved['conflicts'] as $conflict_descriptor ) {
				$all[] = $conflict_descriptor;
			}
			foreach ( $resolved['conflict_records'] as $record ) {
				$conflicts[] = $record;
			}
		}

		foreach ( $no_id as $descriptor ) {
			$candidates[] = $descriptor;
		}

		$source_map = $this->build_source_map( $all );

		return array(
			'candidates'  => array_values( $candidates ),
			'rejected'    => array_values( $rejected_out ),
			'duplicates'  => array_values( $duplicates ),
			'conflicts'   => array_values( $conflicts ),
			'source_map'  => $source_map,
			'descriptors' => array_values( $all ),
		);
	}

	/**
	 * Build contributions from SchemaManager-tagged schema configs/nodes.
	 *
	 * Does not render or emit. Used by V2 diagnostics behind the feature gate.
	 *
	 * @param array $tagged_schemas List of array{source:string,schema:mixed,source_ref?:string}.
	 * @return array Same shape as resolve().
	 */
	public function resolve_tagged_schemas( array $tagged_schemas ) {
		$contributions = array();
		foreach ( $tagged_schemas as $index => $tagged ) {
			if ( ! is_array( $tagged ) ) {
				$contributions[] = $tagged;
				continue;
			}
			$source          = isset( $tagged['source'] ) ? $tagged['source'] : '';
			$ref             = isset( $tagged['source_ref'] ) ? (string) $tagged['source_ref'] : ( 'tagged:' . (int) $index );
			$schema          = isset( $tagged['schema'] ) ? $tagged['schema'] : null;
			$contributions[] = array(
				'source'     => $source,
				'source_ref' => $ref,
				'schema'     => $schema,
			);
		}

		return $this->resolve( $contributions );
	}

	/**
	 * Collect hostile/malformed extension filter returns into rejected descriptors.
	 *
	 * @param mixed $filter_return Filter return value.
	 * @param mixed $object        Optional object context.
	 * @return array Same shape as resolve().
	 */
	public function resolve_extension_filter_return( $filter_return, $object = null ) {
		unset( $object );

		if ( ! is_array( $filter_return ) ) {
			$rejected = SchemaSourceDescriptor::rejected(
				'extension',
				'filter:seovaultseo_set_schema',
				'',
				'hostile_filter_return',
				'Extension filter must return an array of schema contributions.'
			);
			return $this->resolve_descriptors( array( $rejected ), array( $rejected ) );
		}

		// Empty array is a valid "no contributions" result.
		if ( array() === $filter_return ) {
			return $this->resolve( array() );
		}

		$contributions = array();
		foreach ( $filter_return as $index => $item ) {
			if ( ! is_array( $item ) ) {
				$contributions[] = array(
					'source'     => 'extension',
					'source_ref' => 'extension:' . (int) $index,
					// Force malformed shape.
					'invalid'    => $item,
				);
				continue;
			}
			$contributions[] = array(
				'source'     => 'extension',
				'source_ref' => 'extension:' . (int) $index,
				'schema'     => $item,
			);
		}

		return $this->resolve( $contributions );
	}

	/**
	 * Resolve a group of descriptors that share the same canonical @id.
	 *
	 * @param string $entity_id Entity id.
	 * @param array  $group     Descriptors.
	 * @return array{kept:array,duplicates:array,conflicts:array,conflict_records:array}
	 */
	private function resolve_id_group( $entity_id, array $group ) {
		usort(
			$group,
			static function ( $left, $right ) {
				$lp = isset( $left['precedence'] ) ? (int) $left['precedence'] : 0;
				$rp = isset( $right['precedence'] ) ? (int) $right['precedence'] : 0;
				if ( $lp === $rp ) {
					return strcmp( (string) ( $left['source_id'] ?? '' ), (string) ( $right['source_id'] ?? '' ) );
				}
				return $rp <=> $lp;
			}
		);

		$winner = $group[0];
		$hash   = isset( $winner['payload_hash'] ) ? (string) $winner['payload_hash'] : '';

		$kept             = array( $winner );
		$duplicates       = array();
		$conflict_losers  = array();
		$conflict_records = array();

		for ( $i = 1, $count = count( $group ); $i < $count; $i++ ) {
			$other      = $group[ $i ];
			$other_hash = isset( $other['payload_hash'] ) ? (string) $other['payload_hash'] : '';

			if ( '' !== $hash && $other_hash === $hash ) {
				$other['status']      = SchemaSourceDescriptor::STATUS_DUPLICATE;
				$other['diagnostics'] = array_merge(
					isset( $other['diagnostics'] ) && is_array( $other['diagnostics'] ) ? $other['diagnostics'] : array(),
					array(
						array(
							'code'   => 'schema.source.duplicate',
							'reason' => 'Same @id and identical payload as a higher-precedence source.',
							'winner' => isset( $winner['descriptor_id'] ) ? (string) $winner['descriptor_id'] : '',
						),
					)
				);
				$duplicates[]         = $other;
				continue;
			}

			$other['status']      = SchemaSourceDescriptor::STATUS_CONFLICT;
			$other['diagnostics'] = array_merge(
				isset( $other['diagnostics'] ) && is_array( $other['diagnostics'] ) ? $other['diagnostics'] : array(),
				array(
					array(
						'code'   => 'schema.source.id_conflict',
						'reason' => 'Same @id with conflicting payload from a lower-precedence source.',
						'winner' => isset( $winner['descriptor_id'] ) ? (string) $winner['descriptor_id'] : '',
					),
				)
			);
			$conflict_losers[]    = $other;
		}

		if ( ! empty( $conflict_losers ) ) {
			$sources = array( isset( $winner['source'] ) ? (string) $winner['source'] : '' );
			foreach ( $conflict_losers as $loser ) {
				$sources[] = isset( $loser['source'] ) ? (string) $loser['source'] : '';
			}
			$sources = array_values( array_unique( array_filter( $sources ) ) );

			$conflict_records[] = array(
				'code'      => 'schema.source.id_conflict',
				'entity_id' => $entity_id,
				'type'      => isset( $winner['type'] ) ? (string) $winner['type'] : 'Thing',
				'reason'    => 'Conflicting data for the same canonical @id; higher-precedence source wins.',
				'winner'    => SchemaSourceDescriptor::to_diagnostic( $winner ),
				'losers'    => array_map(
					array( SchemaSourceDescriptor::class, 'to_diagnostic' ),
					$conflict_losers
				),
				'sources'   => $sources,
			);
		}

		return array(
			'kept'             => $kept,
			'duplicates'       => $duplicates,
			'conflicts'        => $conflict_losers,
			'conflict_records' => $conflict_records,
		);
	}

	/**
	 * Build a source map keyed by entity_id when present, else descriptor_id.
	 *
	 * @param array $descriptors All descriptors.
	 * @return array<string,array>
	 */
	private function build_source_map( array $descriptors ) {
		$map = array();
		foreach ( $descriptors as $descriptor ) {
			if ( ! is_array( $descriptor ) ) {
				continue;
			}
			$diag = SchemaSourceDescriptor::to_diagnostic( $descriptor );
			$key  = '';
			if ( ! empty( $diag['entity_id'] ) ) {
				// Prefer first (winner) for a given entity id; still record losers under descriptor_id.
				if ( ! isset( $map[ $diag['entity_id'] ] ) ) {
					$key = $diag['entity_id'];
				} else {
					$key = '' !== $diag['descriptor_id'] ? $diag['descriptor_id'] : ( 'index:' . count( $map ) );
				}
			} elseif ( ! empty( $diag['descriptor_id'] ) ) {
				$key = $diag['descriptor_id'];
			} else {
				$key = 'index:' . count( $map );
			}
			$map[ $key ] = $diag;
		}
		return $map;
	}
}
