<?php
/**
 * Orchestrates the per-row match cascade, stages 0-3 (SPEC §3).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Matching;

use EmberPrice\Core\Rows;

/**
 * Runs a single parsed row through the cascade in order, stopping at the
 * first stage that resolves it: learned alias (0) -> exact SKU/GTIN (1) ->
 * exact normalized name (2) -> fuzzy (3).
 *
 * Match() itself is pure (no WordPress dependency, so it's directly
 * fixture-testable); match_and_persist() is the thin WordPress-dependent
 * wrapper that also writes the result to `embp_rows` via Core\Rows.
 */
final class MatchPipeline {

	private const FUZZY_AUTO_THRESHOLD   = 0.90;
	private const FUZZY_REVIEW_THRESHOLD = 0.65;
	private const FUZZY_SHORTLIST_SIZE   = 20;

	/**
	 * Catalog to match against.
	 *
	 * @var CandidateIndex
	 */
	private CandidateIndex $candidates;

	/**
	 * Stage 0 source.
	 *
	 * @var AliasLookupInterface
	 */
	private AliasLookupInterface $aliases;

	/**
	 * Used for stage 3.
	 *
	 * @var FuzzyScorer
	 */
	private FuzzyScorer $scorer;

	/**
	 * Used to bring row text to the same form as the indexed candidates.
	 *
	 * @var Normalizer
	 */
	private Normalizer $normalizer;

	/**
	 * Used by match_and_persist() to write the result back to `embp_rows`.
	 * Left null unless a caller passes one, and only ever instantiated
	 * lazily inside match_and_persist() -- match() itself never touches
	 * this, which is what keeps the rest of this class loadable/testable
	 * without WordPress (Core\Rows hard-exits if ABSPATH isn't defined).
	 *
	 * @var Rows|null
	 */
	private ?Rows $rows;

	/**
	 * Constructor.
	 *
	 * @param CandidateIndex       $candidates Catalog to match against.
	 * @param AliasLookupInterface $aliases    Stage 0 source.
	 * @param FuzzyScorer|null     $scorer     Defaults to a new instance.
	 * @param Normalizer|null      $normalizer Defaults to a new instance.
	 * @param Rows|null            $rows       Used by match_and_persist(); defaults to a new instance, built lazily.
	 */
	public function __construct(
		CandidateIndex $candidates,
		AliasLookupInterface $aliases,
		?FuzzyScorer $scorer = null,
		?Normalizer $normalizer = null,
		?Rows $rows = null
	) {
		$this->candidates = $candidates;
		$this->aliases    = $aliases;
		$this->scorer     = $scorer ?? new FuzzyScorer();
		$this->normalizer = $normalizer ?? new Normalizer();
		$this->rows       = $rows;
	}

	/**
	 * Runs the cascade for one row and writes the result to `embp_rows`.
	 *
	 * @param int   $row_id      `embp_rows.id` to update.
	 * @param array $parsed_row  Keys: name, code, price (as in `embp_rows.parsed`).
	 * @param int   $supplier_id Supplier id (alias lookups are scoped per supplier).
	 */
	public function match_and_persist( int $row_id, array $parsed_row, int $supplier_id ): void {
		$rows = $this->rows ?? new Rows();
		$rows->update_match( $row_id, $this->match( $parsed_row, $supplier_id ) );
	}

	/**
	 * Runs the cascade for one row. Pure: no I/O, no persistence.
	 *
	 * @param array $parsed_row  Keys: name, code, price (as in `embp_rows.parsed`).
	 * @param int   $supplier_id Supplier id (alias lookups are scoped per supplier).
	 *
	 * @return array{product_id: ?int, variation_id: ?int, match_stage: ?string, confidence: ?float, status: string}
	 */
	public function match( array $parsed_row, int $supplier_id ): array {
		if ( null === ( $parsed_row['price'] ?? null ) ) {
			// A row with no price can never be applied; never even suggest
			// a match for it (SPEC: "nunca aplicarse").
			return $this->result( null, null, null, null, 'skipped' );
		}

		$name = (string) ( $parsed_row['name'] ?? '' );
		$code = trim( (string) ( $parsed_row['code'] ?? '' ) );

		$tokens          = $this->normalizer->tokens( $name );
		$normalized_name = implode( ' ', $tokens );

		$alias = $this->aliases->find( $supplier_id, $normalized_name );

		if ( null !== $alias ) {
			return $this->result( $alias['product_id'], $alias['variation_id'], 'alias', 1.0, 'auto' );
		}

		if ( '' !== $code ) {
			$candidate = $this->candidates->find_by_sku( $code ) ?? $this->candidates->find_by_gtin( $code );

			if ( null !== $candidate ) {
				return $this->result( $candidate['product_id'], $candidate['variation_id'], 'sku', 1.0, 'auto' );
			}
		}

		$name_matches = $this->candidates->find_by_normalized_title( $normalized_name );

		if ( 1 === count( $name_matches ) ) {
			$candidate = $name_matches[0];

			return $this->result( $candidate['product_id'], $candidate['variation_id'], 'exact_name', 0.98, 'auto' );
		}

		$best = $this->best_fuzzy_candidate( $name, $tokens );

		if ( null !== $best ) {
			[ $candidate, $score ] = $best;

			if ( $score >= self::FUZZY_AUTO_THRESHOLD ) {
				return $this->result( $candidate['product_id'], $candidate['variation_id'], 'fuzzy', $score, 'auto' );
			}

			if ( $score >= self::FUZZY_REVIEW_THRESHOLD ) {
				return $this->result( $candidate['product_id'], $candidate['variation_id'], 'fuzzy', $score, 'review' );
			}
		}

		return $this->result( null, null, 'none', null, 'review' );
	}

	/**
	 * Highest-scoring candidate among the row's fuzzy shortlist, or null if
	 * the catalog has nothing to compare against.
	 *
	 * @param string   $name   Row description, raw.
	 * @param string[] $tokens Row description, normalized tokens.
	 *
	 * @return array{0: array, 1: float}|null
	 */
	private function best_fuzzy_candidate( string $name, array $tokens ): ?array {
		return $this->scored_candidates( $name, $tokens )[0] ?? null;
	}

	/**
	 * Every shortlisted candidate paired with its fuzzy score, best first --
	 * shared by best_fuzzy_candidate() (stage 3) and top_fuzzy_matches()
	 * (Matching\LlmAdjudicator's stage-4 batches, T-402), so the two never
	 * score the same shortlist differently.
	 *
	 * @param string   $name   Row description, raw.
	 * @param string[] $tokens Row description, normalized tokens.
	 *
	 * @return array<int, array{0: array, 1: float}>
	 */
	private function scored_candidates( string $name, array $tokens ): array {
		$shortlist = $this->candidates->top_candidates( $tokens, self::FUZZY_SHORTLIST_SIZE );

		$scored = array();

		foreach ( $shortlist as $candidate ) {
			$scored[] = array( $candidate, $this->scorer->score( $name, $candidate['title'], $candidate['brand'] ) );
		}

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

		return $scored;
	}

	/**
	 * Top $limit fuzzy candidates for a row, regardless of score -- unlike
	 * best_fuzzy_candidate(), which only cares about the single winner, this
	 * is for Matching\LlmAdjudicator (stage 4, SPEC §3.3): a row already
	 * stuck in `review` after stages 0-3 still needs its shortlist handed to
	 * the LLM, even if every candidate scored below the review threshold.
	 *
	 * @param array $parsed_row Keys: name, code, price (as in `embp_rows.parsed`).
	 * @param int   $limit      Max candidates to return.
	 *
	 * @return array<int, array{product_id: int, variation_id: ?int, sku: ?string, title: string}>
	 */
	public function top_fuzzy_matches( array $parsed_row, int $limit = 5 ): array {
		$name   = (string) ( $parsed_row['name'] ?? '' );
		$tokens = $this->normalizer->tokens( $name );

		return array_map(
			static fn( array $entry ): array => array(
				'product_id'   => $entry[0]['product_id'],
				'variation_id' => $entry[0]['variation_id'],
				'sku'          => $entry[0]['sku'],
				'title'        => $entry[0]['title'],
			),
			array_slice( $this->scored_candidates( $name, $tokens ), 0, $limit )
		);
	}

	/**
	 * Builds the standard result shape -- the single funnel every stage's
	 * result passes through.
	 *
	 * @param ?int    $product_id   Matched product id, if any.
	 * @param ?int    $variation_id Matched variation id, if any.
	 * @param ?string $stage        Cascade stage that resolved it, or 'none'/null.
	 * @param ?float  $confidence   Match confidence, if any.
	 * @param string  $status       `embp_rows.status`.
	 */
	private function result( ?int $product_id, ?int $variation_id, ?string $stage, ?float $confidence, string $status ): array {
		return array(
			'product_id'   => $product_id,
			'variation_id' => $variation_id,
			'match_stage'  => $stage,
			'confidence'   => $confidence,
			'status'       => $status,
		);
	}
}
