<?php
/**
 * Detects which columns hold price/code/description (SPEC §4).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Ingestion;

/**
 * Scores every column of a parsed row matrix (as produced by CsvParser or
 * XlsxParser) against the three roles EmberPrice cares about, using the
 * heuristics from SPEC §4. Pure PHP, no WordPress dependency.
 */
final class ColumnDetector {

	private const PRICE_MIN_RATIO        = 0.8;
	private const CODE_MAX_LENGTH        = 20;
	private const CODE_MIN_SHAPE_RATIO   = 0.8;
	private const DESCRIPTION_MIN_LENGTH = 15;
	private const DESCRIPTION_MIN_RATIO  = 0.5;
	private const ROLE_MIN_CONFIDENCE    = 0.45;

	/**
	 * Header aliases ordered from most specific to broadest.
	 *
	 * @var array<string, string[]>
	 */
	private const HEADER_ALIASES = array(
		'price'       => array( 'preciounidad', 'preciolista', 'unitprice', 'punit', 'precio', 'price', 'importe', 'costo', 'cost', 'valor' ),
		'code'        => array( 'idproducto', 'productid', 'productcode', 'itemcode', 'referencia', 'codigo', 'code', 'sku', 'ean', 'gtin', 'mpn', 'ref', 'art' ),
		'description' => array( 'nombreproducto', 'productname', 'descripcion', 'description', 'detalle', 'articulo', 'producto', 'product', 'item', 'name' ),
	);

	/**
	 * Headers that may be numeric/string-shaped but are never one of the
	 * three import roles.
	 *
	 * @var string[]
	 */
	private const IRRELEVANT_HEADERS = array(
		'stock',
		'existencia',
		'unidadesenexistencia',
		'unidadesenpedido',
		'pedido',
		'reorder',
		'nivel',
		'cantidad',
		'quantity',
		'suspendido',
		'discontinued',
		'proveedor',
		'supplier',
		'categoria',
		'category',
		'iva',
		'tax',
		'marca',
		'brand',
	);

	/**
	 * Used to score the price role.
	 *
	 * @var PriceParser
	 */
	private PriceParser $price_parser;

	/**
	 * Constructor.
	 *
	 * @param PriceParser|null $price_parser Defaults to a new instance.
	 */
	public function __construct( ?PriceParser $price_parser = null ) {
		$this->price_parser = $price_parser ?? new PriceParser();
	}

	/**
	 * Detects the role of every column with a confident-enough score.
	 * Columns matching no role (below every threshold) are omitted.
	 *
	 * @param array<int, string[]> $rows       Full row matrix (as from CsvParser/XlsxParser).
	 * @param int|null             $header_row Index of the header row, if known; data is
	 *                                         assumed to start right after it (or from row 0 if null).
	 *
	 * @return array<int, array{role: string, confidence: float}>
	 */
	public function detect( array $rows, ?int $header_row = null ): array {
		$data_rows = array_slice( $rows, ( $header_row ?? -1 ) + 1 );

		if ( empty( $data_rows ) ) {
			return array();
		}

		$column_count = max( array_map( 'count', $data_rows ) );
		$headers      = null !== $header_row ? ( $rows[ $header_row ] ?? array() ) : array();
		$candidates   = array();

		for ( $column = 0; $column < $column_count; $column++ ) {
			$values      = $this->column_values( $data_rows, $column );
			$header_role = $this->header_role( (string) ( $headers[ $column ] ?? '' ) );

			$scores = array(
				'price'       => $this->combine_header_and_data_score( 'price', $header_role, $this->score_price( $values ) ),
				'code'        => $this->combine_header_and_data_score( 'code', $header_role, $this->score_code( $values ) ),
				'description' => $this->combine_header_and_data_score( 'description', $header_role, $this->score_description( $values ) ),
			);

			foreach ( $scores as $role => $confidence ) {
				if ( $confidence >= self::ROLE_MIN_CONFIDENCE ) {
					$candidates[] = array(
						'column'     => $column,
						'role'       => $role,
						'confidence' => min( 1.0, $confidence ),
					);
				}
			}
		}

		usort(
			$candidates,
			static function ( array $left, array $right ): int {
				if ( $left['confidence'] === $right['confidence'] ) {
					$priority = array(
						'price'       => 0,
						'description' => 1,
						'code'        => 2,
					);

					return $priority[ $left['role'] ] <=> $priority[ $right['role'] ];
				}

				return $right['confidence'] <=> $left['confidence'];
			}
		);

		$result       = array();
		$claimed_role = array();

		foreach ( $candidates as $candidate ) {
			if ( isset( $result[ $candidate['column'] ] ) || isset( $claimed_role[ $candidate['role'] ] ) ) {
				continue;
			}

			$result[ $candidate['column'] ]     = array(
				'role'       => $candidate['role'],
				'confidence' => $candidate['confidence'],
			);
			$claimed_role[ $candidate['role'] ] = true;
		}

		ksort( $result );

		return $result;
	}

	/**
	 * Resolves a normalized header to a role, `ignore`, or null when the
	 * header gives no useful semantic signal.
	 *
	 * @param string $header Raw header text.
	 */
	private function header_role( string $header ): ?string {
		$normalized = strtolower(
			strtr(
				$header,
				array(
					'á' => 'a',
					'é' => 'e',
					'í' => 'i',
					'ó' => 'o',
					'ú' => 'u',
					'ü' => 'u',
					'ñ' => 'n',
				)
			)
		);
		$normalized = (string) preg_replace( '/[^a-z0-9]+/', '', $normalized );

		if ( '' === $normalized ) {
			return null;
		}

		foreach ( self::HEADER_ALIASES as $role => $aliases ) {
			foreach ( $aliases as $alias ) {
				if ( $normalized === $alias || ( strlen( $alias ) >= 5 && str_contains( $normalized, $alias ) ) ) {
					return $role;
				}
			}
		}

		foreach ( self::IRRELEVANT_HEADERS as $irrelevant ) {
			if ( str_contains( $normalized, $irrelevant ) ) {
				return 'ignore';
			}
		}

		return null;
	}

	/**
	 * Header semantics outrank numeric shape. A recognized header for one
	 * role suppresses the other roles; unknown headers still use the
	 * original data heuristics at a slightly reduced confidence.
	 *
	 * @param string      $role        Role being scored.
	 * @param string|null $header_role Header semantic result.
	 * @param float       $data_score  Original value-shape score.
	 */
	private function combine_header_and_data_score( string $role, ?string $header_role, float $data_score ): float {
		if ( $role === $header_role ) {
			return 0.7 + ( 0.3 * $data_score );
		}

		if ( null !== $header_role ) {
			return 0.0;
		}

		return 0.85 * $data_score;
	}

	/**
	 * Non-empty values for a single column across all data rows.
	 *
	 * @param array<int, string[]> $rows   Data rows (header already excluded).
	 * @param int                  $column Column index.
	 *
	 * @return string[]
	 */
	private function column_values( array $rows, int $column ): array {
		$values = array();

		foreach ( $rows as $row ) {
			$values[] = $row[ $column ] ?? '';
		}

		return array_values( array_filter( $values, static fn( string $value ) => '' !== $value ) );
	}

	/**
	 * >80% of values parseable as a price and price-shaped (see
	 * {@see self::looks_like_price()}).
	 *
	 * @param string[] $values Non-empty column values.
	 */
	private function score_price( array $values ): float {
		if ( empty( $values ) ) {
			return 0.0;
		}

		$parseable = 0;

		foreach ( $values as $value ) {
			if ( $this->looks_like_price( $value ) && null !== $this->price_parser->parse( $value ) ) {
				++$parseable;
			}
		}

		$ratio = $parseable / count( $values );

		return $ratio >= self::PRICE_MIN_RATIO ? $ratio : 0.0;
	}

	/**
	 * Extra shape check on top of PriceParser::parse() being non-null:
	 * rejects values that are technically parseable as a bare number but
	 * don't actually look like a price -- a percentage ("21%"), a code
	 * with letters ("AB-778", whose hyphen would otherwise read as a minus
	 * sign), or a leading-zero code ("0001542", which a real price is
	 * never written with).
	 *
	 * @param string $value A single column value.
	 */
	private function looks_like_price( string $value ): bool {
		if ( str_contains( $value, '%' ) || 1 === preg_match( '/\p{L}/u', $value ) ) {
			return false;
		}

		$digits_only = preg_replace( '/\D/', '', $value );

		if ( null !== $digits_only && strlen( $digits_only ) > 1 && '0' === $digits_only[0] ) {
			return false;
		}

		return true;
	}

	/**
	 * Short (<20 chars) alphanumeric strings, mostly unique.
	 *
	 * @param string[] $values Non-empty column values.
	 */
	private function score_code( array $values ): float {
		if ( empty( $values ) ) {
			return 0.0;
		}

		$code_shaped = 0;

		foreach ( $values as $value ) {
			if ( $this->looks_like_code( $value ) ) {
				++$code_shaped;
			}
		}

		$shape_ratio = $code_shaped / count( $values );

		if ( $shape_ratio < self::CODE_MIN_SHAPE_RATIO ) {
			return 0.0;
		}

		$uniqueness_ratio = count( array_unique( $values ) ) / count( $values );

		return $shape_ratio * $uniqueness_ratio;
	}

	/**
	 * Whether a value is shaped like a product code.
	 *
	 * @param string $value A single column value.
	 */
	private function looks_like_code( string $value ): bool {
		return mb_strlen( $value ) < self::CODE_MAX_LENGTH
			&& 1 === preg_match( '/^[\p{L}\p{N}\-_.\/]+$/u', $value );
	}

	/**
	 * Mostly long (>=15 char) strings.
	 *
	 * @param string[] $values Non-empty column values.
	 */
	private function score_description( array $values ): float {
		if ( empty( $values ) ) {
			return 0.0;
		}

		$long_count = 0;

		foreach ( $values as $value ) {
			if ( mb_strlen( $value ) >= self::DESCRIPTION_MIN_LENGTH ) {
				++$long_count;
			}
		}

		$ratio = $long_count / count( $values );

		return $ratio >= self::DESCRIPTION_MIN_RATIO ? $ratio : 0.0;
	}
}
