<?php
/**
 * In-memory index of the catalog used by the match pipeline (SPEC §3).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Matching;

/**
 * Flattens products + variations from a CatalogRepositoryInterface into a
 * flat candidate list (title normalized once, up front) and exposes the
 * lookups the cascade needs: exact SKU/GTIN (stage 1), exact normalized
 * title (stage 2), and a token-overlap shortlist for FuzzyScorer (stage 3)
 * so it never has to score against the whole catalog. Pure PHP, no
 * WordPress dependency -- the repository is where WordPress/WooCommerce
 * access happens.
 */
final class CandidateIndex {

	/**
	 * All candidates, in insertion order.
	 *
	 * @var array<int, array{product_id: int, variation_id: ?int, sku: string, gtin: string, brand: string, title: string, normalized_title: string, tokens: string[]}>
	 */
	private array $candidates = array();

	/**
	 * Uppercased SKU => candidate.
	 *
	 * @var array<string, array>
	 */
	private array $by_sku = array();

	/**
	 * Uppercased GTIN => candidate.
	 *
	 * @var array<string, array>
	 */
	private array $by_gtin = array();

	/**
	 * Normalized title => list of candidates (more than one product can
	 * normalize to the same title, e.g. two variations with an identical
	 * name save for a size that already collapsed via unit standardization).
	 *
	 * @var array<string, array<int, array>>
	 */
	private array $by_normalized_title = array();

	/**
	 * Used to normalize every candidate title the same way row descriptions
	 * are normalized, so stage 2 (exact normalized title) can hash-match.
	 *
	 * @var Normalizer
	 */
	private Normalizer $normalizer;

	/**
	 * Builds the index eagerly from the repository.
	 *
	 * @param CatalogRepositoryInterface $repository Source of raw catalog rows.
	 * @param Normalizer|null            $normalizer Defaults to a new instance.
	 */
	public function __construct( CatalogRepositoryInterface $repository, ?Normalizer $normalizer = null ) {
		$this->normalizer = $normalizer ?? new Normalizer();

		foreach ( $repository->get_products() as $product ) {
			$this->index_product( $product );
		}
	}

	/**
	 * A variable product's parent has no single price of its own, so only
	 * its variations become candidates; a simple product indexes itself.
	 *
	 * @param array $product Raw row from CatalogRepositoryInterface::get_products().
	 */
	private function index_product( array $product ): void {
		$variations = $product['variations'] ?? array();

		if ( ! empty( $variations ) ) {
			foreach ( $variations as $variation ) {
				$this->add_candidate(
					(int) $product['product_id'],
					(int) $variation['variation_id'],
					(string) $variation['sku'],
					$this->inherited( $variation['gtin'] ?? '', $product['gtin'] ?? '' ),
					$this->inherited( $variation['brand'] ?? '', $product['brand'] ?? '' ),
					(string) $variation['title']
				);
			}
			return;
		}

		$this->add_candidate(
			(int) $product['product_id'],
			null,
			(string) $product['sku'],
			(string) ( $product['gtin'] ?? '' ),
			(string) ( $product['brand'] ?? '' ),
			(string) $product['title']
		);
	}

	/**
	 * A variation's own value if it has one, otherwise its parent's -- a
	 * variation rarely carries its own GTIN/brand distinct from the parent
	 * product, so falling back avoids losing a stage-1/attribute-bonus
	 * signal just because the variation-level field is blank.
	 *
	 * @param string $own          Variation-level value (may be empty).
	 * @param string $parent_value Parent product-level value (may be empty).
	 */
	private function inherited( string $own, string $parent_value ): string {
		return '' !== $own ? $own : $parent_value;
	}

	/**
	 * Normalizes, stores, and indexes a single candidate.
	 *
	 * @param int    $product_id   Product id.
	 * @param ?int   $variation_id Variation id, or null for a simple product.
	 * @param string $sku          SKU (may be empty).
	 * @param string $gtin         GTIN/EAN/MPN (may be empty).
	 * @param string $brand        Brand name (may be empty).
	 * @param string $title        Product/variation title.
	 */
	private function add_candidate( int $product_id, ?int $variation_id, string $sku, string $gtin, string $brand, string $title ): void {
		$candidate = array(
			'product_id'       => $product_id,
			'variation_id'     => $variation_id,
			'sku'              => $sku,
			'gtin'             => $gtin,
			'brand'            => $brand,
			'title'            => $title,
			'normalized_title' => $this->normalizer->normalize( $title ),
			'tokens'           => $this->normalizer->tokens( $title ),
		);

		$this->candidates[] = $candidate;

		$sku_key = $this->code_key( $sku );
		if ( '' !== $sku_key ) {
			$this->by_sku[ $sku_key ] = $candidate;
		}

		$gtin_key = $this->code_key( $gtin );
		if ( '' !== $gtin_key ) {
			$this->by_gtin[ $gtin_key ] = $candidate;
		}

		if ( '' !== $candidate['normalized_title'] ) {
			$this->by_normalized_title[ $candidate['normalized_title'] ][] = $candidate;
		}
	}

	/**
	 * Case/whitespace-insensitive lookup key for SKU/GTIN matching -- unlike
	 * Normalizer, this must NOT reorder or strip characters, since codes are
	 * matched verbatim (just case- and padding-insensitively).
	 *
	 * @param string $code Raw SKU or GTIN.
	 */
	private function code_key( string $code ): string {
		return mb_strtoupper( trim( $code ), 'UTF-8' );
	}

	/**
	 * Exact SKU match (stage 1), or null.
	 *
	 * @param string $sku Supplier row's code, as-is.
	 */
	public function find_by_sku( string $sku ): ?array {
		return $this->by_sku[ $this->code_key( $sku ) ] ?? null;
	}

	/**
	 * Exact GTIN/EAN/MPN match (stage 1), or null.
	 *
	 * @param string $gtin Supplier row's code, as-is.
	 */
	public function find_by_gtin( string $gtin ): ?array {
		return $this->by_gtin[ $this->code_key( $gtin ) ] ?? null;
	}

	/**
	 * Exact normalized-title match(es) (stage 2). More than one candidate
	 * can come back; the caller decides how to handle the ambiguity.
	 *
	 * @param string $normalized_title Already-normalized text (see Normalizer).
	 *
	 * @return array<int, array>
	 */
	public function find_by_normalized_title( string $normalized_title ): array {
		return $this->by_normalized_title[ $normalized_title ] ?? array();
	}

	/**
	 * Shortlist of candidates worth scoring against a row for the fuzzy
	 * stage: the whole catalog for small catalogs, or the top matches by
	 * token overlap once it's large enough that scoring everything would be
	 * wasteful (the in-memory equivalent of SPEC §3.2's FULLTEXT/LIKE
	 * prefilter -- the SQL-backed version is a repository-level concern for
	 * catalogs too large to hold every scoring pass in memory).
	 *
	 * @param string[] $query_tokens Normalized tokens of the row being matched.
	 * @param int      $limit        Max candidates to return.
	 *
	 * @return array<int, array>
	 */
	public function top_candidates( array $query_tokens, int $limit = 20 ): array {
		if ( count( $this->candidates ) <= $limit ) {
			return $this->candidates;
		}

		$scored = array();

		foreach ( $this->candidates as $candidate ) {
			$overlap = count( array_intersect( $query_tokens, $candidate['tokens'] ) );

			if ( $overlap > 0 ) {
				$scored[] = array( $overlap, $candidate );
			}
		}

		usort( $scored, static fn( array $a, array $b ): int => $b[0] <=> $a[0] );

		return array_map( static fn( array $pair ) => $pair[1], array_slice( $scored, 0, $limit ) );
	}

	/**
	 * All candidates, in insertion order.
	 *
	 * @return array<int, array>
	 */
	public function all(): array {
		return $this->candidates;
	}

	/**
	 * Total number of indexed candidates (products + variations).
	 */
	public function count(): int {
		return count( $this->candidates );
	}
}
