<?php
/**
 * Scores MatchPipeline against fixture expectations (T-208 regression harness).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Matching;

/**
 * Tallies auto-match precision and overall recall for a fixture list
 * against its `expected/*.expected.json`, with a per-stage breakdown for
 * diagnosing which stage of the cascade regressed. Fixture parsing and
 * multi-list discovery live in Rest\RegressionCommand (Matching can't
 * import Ingestion); this class only scores already-parsed rows, which is
 * what keeps it pure and directly unit-testable.
 */
final class RegressionHarness {

	/**
	 * Min_stage values this harness (stages 0-3) can actually resolve --
	 * an unresolved row whose min_stage is beyond this (e.g. 'llm') isn't a
	 * regression, it's a row waiting on a stage that doesn't exist yet.
	 */
	private const IN_SCOPE_STAGES = array( 'alias', 'sku', 'exact_name', 'fuzzy' );

	/**
	 * Pipeline under test.
	 *
	 * @var MatchPipeline
	 */
	private MatchPipeline $pipeline;

	/**
	 * Constructor.
	 *
	 * @param MatchPipeline $pipeline Pipeline under test.
	 */
	public function __construct( MatchPipeline $pipeline ) {
		$this->pipeline = $pipeline;
	}

	/**
	 * Scores one fixture list.
	 *
	 * @param array<int, array{name: string, code: ?string, price: ?float}>           $parsed_rows    Rows parsed from the fixture list.
	 * @param array<int, array{row_hint: string, expect: ?array, min_stage?: string}> $expected_cases The fixture's `expected["rows"]`.
	 *
	 * @return array{stage_totals: array<string, array{total: int, correct: int}>, recall_total: int, recall_resolved: int, mismatches: array}
	 */
	public function evaluate( array $parsed_rows, array $expected_cases ): array {
		$stage_totals    = array();
		$recall_total    = 0;
		$recall_resolved = 0;
		$mismatches      = array();

		foreach ( $expected_cases as $case ) {
			$row = $this->find_row_by_hint( $parsed_rows, $case['row_hint'] );

			if ( null === $row ) {
				$mismatches[] = array(
					'row_hint' => $case['row_hint'],
					'issue'    => 'row_hint not found in the parsed list',
				);
				continue;
			}

			$result           = $this->pipeline->match( $row, 1 );
			$expect           = $case['expect'];
			$matches_expected = null !== $expect
				&& $expect['product_id'] === $result['product_id']
				&& ( $expect['variation_id'] ?? null ) === $result['variation_id'];

			if ( 'auto' === $result['status'] ) {
				$stage = (string) $result['match_stage'];

				$stage_totals[ $stage ]['total']   = ( $stage_totals[ $stage ]['total'] ?? 0 ) + 1;
				$stage_totals[ $stage ]['correct'] = ( $stage_totals[ $stage ]['correct'] ?? 0 ) + ( $matches_expected ? 1 : 0 );

				if ( ! $matches_expected ) {
					$mismatches[] = array(
						'row_hint' => $case['row_hint'],
						'issue'    => null === $expect
							? 'false positive: auto-matched a row expected to have no match'
							: 'auto-matched the wrong product',
						'got'      => $result,
						'expected' => $expect,
					);
				}
			}

			if ( null !== $expect ) {
				++$recall_total;

				if ( $matches_expected ) {
					++$recall_resolved;
				} elseif ( in_array( $case['min_stage'] ?? null, self::IN_SCOPE_STAGES, true ) ) {
					$mismatches[] = array(
						'row_hint' => $case['row_hint'],
						'issue'    => "not resolved (min_stage={$case['min_stage']}, within stages 0-3)",
						'got'      => $result,
						'expected' => $expect,
					);
				}
			}
		}

		return compact( 'stage_totals', 'recall_total', 'recall_resolved', 'mismatches' );
	}

	/**
	 * Combines evaluate() results from multiple fixture lists into one
	 * overall report (precision/recall aggregated across every list).
	 *
	 * @param array<string, array> $results Fixture list name => evaluate() output.
	 *
	 * @return array{precision: float, recall: float, stage_totals: array, mismatches: array, auto_total: int, auto_correct: int, recall_total: int, recall_resolved: int}
	 */
	public function aggregate( array $results ): array {
		$stage_totals    = array();
		$recall_total    = 0;
		$recall_resolved = 0;
		$mismatches      = array();

		foreach ( $results as $list => $result ) {
			foreach ( $result['stage_totals'] as $stage => $counts ) {
				$stage_totals[ $stage ]['total']   = ( $stage_totals[ $stage ]['total'] ?? 0 ) + $counts['total'];
				$stage_totals[ $stage ]['correct'] = ( $stage_totals[ $stage ]['correct'] ?? 0 ) + $counts['correct'];
			}

			$recall_total    += $result['recall_total'];
			$recall_resolved += $result['recall_resolved'];

			foreach ( $result['mismatches'] as $mismatch ) {
				$mismatches[] = array( 'list' => $list ) + $mismatch;
			}
		}

		$auto_total   = array_sum( array_column( $stage_totals, 'total' ) );
		$auto_correct = array_sum( array_column( $stage_totals, 'correct' ) );

		return array(
			'precision'       => $auto_total > 0 ? $auto_correct / $auto_total : 1.0,
			'recall'          => $recall_total > 0 ? $recall_resolved / $recall_total : 1.0,
			'stage_totals'    => $stage_totals,
			'mismatches'      => $mismatches,
			'auto_total'      => $auto_total,
			'auto_correct'    => $auto_correct,
			'recall_total'    => $recall_total,
			'recall_resolved' => $recall_resolved,
		);
	}

	/**
	 * Finds the parsed row identified by the fixture's row_hint: an exact
	 * match on the code column wins first (codes like "778" and "AB-778"
	 * can co-exist in the same list, and "778" is a substring of "AB-778"
	 * -- an exact match avoids picking the wrong one), falling back to a
	 * substring search over code+description for hints that are only ever
	 * a fragment of a longer cell (e.g. the section-banner row in the
	 * ferreteria fixture).
	 *
	 * @param array<int, array{name: string, code: ?string, price: ?float}> $rows Parsed rows.
	 * @param string                                                        $hint Fixture row_hint.
	 */
	private function find_row_by_hint( array $rows, string $hint ): ?array {
		foreach ( $rows as $row ) {
			if ( trim( (string) ( $row['code'] ?? '' ) ) === $hint ) {
				return $row;
			}
		}

		foreach ( $rows as $row ) {
			$haystack = trim( (string) ( $row['code'] ?? '' ) ) . ' ' . (string) ( $row['name'] ?? '' );

			if ( str_contains( $haystack, $hint ) ) {
				return $row;
			}
		}

		return null;
	}
}
