<?php
/**
 * `wp embp:regression` -- matching quality gate against tests/fixtures/ (T-208).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Rest;

// Deliberately NO `defined( 'ABSPATH' ) || exit;` guard, unlike every other
// Rest/ class: build_report() is designed to also run in the WordPress-free
// Unit suite (the `@group regression` PHPUnit test), so this file must stay
// loadable without WordPress. Only run() -- the actual WP-CLI callback --
// touches \WP_CLI, and that class reference is resolved lazily at call time,
// never at load time, so it's never invoked outside a real WP-CLI process.

use EmberPrice\Ingestion\ColumnDetector;
use EmberPrice\Ingestion\CsvParser;
use EmberPrice\Ingestion\PriceParser;
use EmberPrice\Ingestion\XlsxParser;
use EmberPrice\Matching\AliasLookupInterface;
use EmberPrice\Matching\CandidateIndex;
use EmberPrice\Matching\JsonCatalogRepository;
use EmberPrice\Matching\MatchPipeline;
use EmberPrice\Matching\RegressionHarness;

/**
 * Parses every list in `tests/fixtures/lists/` (Ingestion), runs it through
 * MatchPipeline against `tests/fixtures/catalog.json` (Matching), and scores
 * the result against `tests/fixtures/expected/*.expected.json`
 * (Matching\RegressionHarness) -- the orchestration itself has to live here
 * rather than in Matching because it needs Ingestion to parse the fixture
 * files, and Matching can't import Ingestion (see CLAUDE.md's dependency
 * matrix); Rest is the layer that's allowed to see everything.
 */
final class RegressionCommand {

	/**
	 * SPEC/T-208's quality bar: below either of these, the harness fails.
	 */
	private const PRECISION_THRESHOLD = 0.98;
	private const RECALL_THRESHOLD    = 0.70;

	/**
	 * Runs every fixture list against catalog.json and returns the
	 * aggregated report. WordPress-free core shared by run() (the WP-CLI
	 * callback) and the `@group regression` PHPUnit test.
	 *
	 * @return array{pass: bool, precision: float, recall: float, stage_totals: array, mismatches: array, auto_total: int, auto_correct: int, recall_total: int, recall_resolved: int}
	 */
	public function build_report(): array {
		$fixtures_dir = dirname( __DIR__, 2 ) . '/tests/fixtures';

		$catalog  = new CandidateIndex( new JsonCatalogRepository( $fixtures_dir . '/catalog.json' ) );
		$pipeline = new MatchPipeline( $catalog, $this->no_op_alias_lookup() );
		$harness  = new RegressionHarness( $pipeline );

		$results = array();

		foreach ( $this->discover_fixture_lists( $fixtures_dir ) as $list_name => $paths ) {
			$parsed_rows           = $this->parse_fixture_file( $paths['file'] );
			$expected              = json_decode( (string) file_get_contents( $paths['expected'] ), true ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- deliberately WordPress-free; local fixture, not a remote URL.
			$results[ $list_name ] = $harness->evaluate( $parsed_rows, $expected['rows'] );
		}

		$report         = $harness->aggregate( $results );
		$report['pass'] = $report['precision'] >= self::PRECISION_THRESHOLD && $report['recall'] >= self::RECALL_THRESHOLD;

		return $report;
	}

	/**
	 * WP-CLI callback for `wp embp:regression`.
	 */
	public function run(): void {
		$report = $this->build_report();

		\WP_CLI::log( sprintf( 'Precision: %.3f (%d/%d auto matches correct)', $report['precision'], $report['auto_correct'], $report['auto_total'] ) );
		\WP_CLI::log( sprintf( 'Recall:    %.3f (%d/%d expected rows resolved)', $report['recall'], $report['recall_resolved'], $report['recall_total'] ) );
		\WP_CLI::log( '' );
		\WP_CLI::log( 'By stage:' );

		foreach ( $report['stage_totals'] as $stage => $counts ) {
			\WP_CLI::log( sprintf( '  %-12s %d/%d correct', $stage, $counts['correct'], $counts['total'] ) );
		}

		foreach ( $report['mismatches'] as $mismatch ) {
			\WP_CLI::warning( sprintf( '[%s] %s: %s', $mismatch['list'], $mismatch['row_hint'], $mismatch['issue'] ) );
		}

		if ( $report['pass'] ) {
			\WP_CLI::success( 'Regression harness passed.' );
			return;
		}

		\WP_CLI::error(
			sprintf(
				'Regression harness failed (need precision >= %.2f and recall >= %.2f).',
				self::PRECISION_THRESHOLD,
				self::RECALL_THRESHOLD
			)
		);
	}

	/**
	 * A stage-0 source that never resolves anything -- the regression
	 * fixtures have no learned aliases, and the harness is about the
	 * cold-start cascade (stages 1-3), not alias memory.
	 */
	private function no_op_alias_lookup(): AliasLookupInterface {
		return new class() implements AliasLookupInterface {
			/**
			 * {@inheritDoc}
			 *
			 * @param int    $supplier_id      Supplier id.
			 * @param string $alias_normalized Already-normalized alias text.
			 */
			public function find( int $supplier_id, string $alias_normalized ): ?array { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed -- required by AliasLookupInterface; this stub never resolves anything on purpose.
				return null;
			}
		};
	}

	/**
	 * Every `tests/fixtures/lists/*.csv|*.xlsx` file that has a matching
	 * `tests/fixtures/expected/{basename}.expected.json`.
	 *
	 * @param string $fixtures_dir Path to `tests/fixtures`.
	 *
	 * @return array<string, array{file: string, expected: string}>
	 */
	private function discover_fixture_lists( string $fixtures_dir ): array {
		$files = array_merge(
			(array) glob( $fixtures_dir . '/lists/*.csv' ),
			(array) glob( $fixtures_dir . '/lists/*.xlsx' )
		);

		$lists = array();

		foreach ( $files as $file ) {
			$base     = pathinfo( $file, PATHINFO_FILENAME );
			$expected = $fixtures_dir . '/expected/' . $base . '.expected.json';

			if ( file_exists( $expected ) ) {
				$lists[ $base ] = array(
					'file'     => $file,
					'expected' => $expected,
				);
			}
		}

		return $lists;
	}

	/**
	 * Parses a fixture list exactly like Rest\JobsController::map_job()
	 * parses a real upload: detect header/columns, then extract
	 * name/code/price per row.
	 *
	 * @param string $path Path to the fixture CSV/XLSX.
	 *
	 * @return array<int, array{name: string, code: ?string, price: ?float}>
	 */
	private function parse_fixture_file( string $path ): array {
		$extension   = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) );
		$parsed_file = 'csv' === $extension ? ( new CsvParser() )->parse( $path ) : ( new XlsxParser() )->parse( $path );

		$detected = ( new ColumnDetector() )->detect( $parsed_file['rows'], $parsed_file['header_row'] );
		$roles    = array_map( static fn( array $c ): string => $c['role'], $detected );

		$price_column       = array_search( 'price', $roles, true );
		$description_column = array_search( 'description', $roles, true );
		$code_column        = array_search( 'code', $roles, true );

		$price_parser = new PriceParser();
		$rows         = array();

		foreach ( array_slice( $parsed_file['rows'], $parsed_file['header_row'] + 1, null, true ) as $raw ) {
			$rows[] = array(
				'name'  => $raw[ $description_column ] ?? '',
				'code'  => false !== $code_column ? ( $raw[ $code_column ] ?? null ) : null,
				'price' => $price_parser->parse( $raw[ $price_column ] ?? null ),
			);
		}

		return $rows;
	}
}
