<?php
/**
 * Composite fuzzy match score between a supplier row and a candidate (SPEC §3.2).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Matching;

/**
 * Score = 0.60 x token_set_ratio + 0.25 x trigram_similarity + 0.15 x attribute_bonus,
 * then a hard x0.5 penalty on the whole score if a number-with-unit
 * (500ml, 13mm, 20l) present on both sides disagrees -- a different
 * quantity is almost always a different product, so this overrides an
 * otherwise-high text similarity. Pure PHP, no WordPress dependency.
 */
final class FuzzyScorer {

	/**
	 * Applied to the whole composite score when a shared number+unit
	 * attribute (e.g. both sides mention liters, but different amounts)
	 * disagrees between the two sides.
	 */
	private const ATTRIBUTE_MISMATCH_PENALTY = 0.5;

	private const WEIGHT_TOKEN_SET_RATIO = 0.60;
	private const WEIGHT_TRIGRAM         = 0.25;
	private const WEIGHT_ATTRIBUTE_BONUS = 0.15;

	/**
	 * Recognized unit abbreviations mapped to a canonical symbol, so
	 * "20lt", "20 l" and "20L" all compare equal. Deliberately a fixed
	 * whitelist (not "any short word after a number"): a model suffix like
	 * "GSB 13 RE" must NOT be read as a "13 re" measurement.
	 */
	private const UNITS = array(
		'lts' => 'l',
		'lt'  => 'l',
		'l.'  => 'l',
		'l'   => 'l',
		'grs' => 'g',
		'gr'  => 'g',
		'g'   => 'g',
		'mts' => 'm',
		'mt'  => 'm',
		'm'   => 'm',
		'mm'  => 'mm',
		'cm'  => 'cm',
		'km'  => 'km',
		'ml'  => 'ml',
		'kg'  => 'kg',
		'mg'  => 'mg',
		'w'   => 'w',
		'kw'  => 'kw',
		'v'   => 'v',
		'hz'  => 'hz',
	);

	/**
	 * Used to bring both sides to a comparable form before scoring.
	 *
	 * @var Normalizer
	 */
	private Normalizer $normalizer;

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

	/**
	 * Scores how likely $row_text and $candidate_title refer to the same
	 * product, in the [0, 1] range (the attribute mismatch penalty is the
	 * only thing that can push related-but-different-quantity pairs low
	 * despite near-identical text).
	 *
	 * @param string $row_text          Supplier row description, raw.
	 * @param string $candidate_title   Catalog candidate title, raw.
	 * @param string $candidate_brand   Candidate's brand name, if known.
	 */
	public function score( string $row_text, string $candidate_title, string $candidate_brand = '' ): float {
		$row_tokens       = $this->normalizer->tokens( $row_text );
		$candidate_tokens = $this->normalizer->tokens( $candidate_title );

		if ( empty( $row_tokens ) || empty( $candidate_tokens ) ) {
			// An empty token set is technically a subset of anything, which
			// would otherwise make token_set_ratio() report a perfect 1.0 --
			// "nothing" must never look like a match.
			return 0.0;
		}

		$token_set_ratio = $this->token_set_ratio( $row_tokens, $candidate_tokens );
		$trigram         = $this->trigram_similarity(
			$this->normalizer->normalize( $row_text ),
			$this->normalizer->normalize( $candidate_title )
		);
		$attribute_bonus = $this->brand_matches( $row_tokens, $candidate_brand ) ? 1.0 : 0.0;

		$score = self::WEIGHT_TOKEN_SET_RATIO * $token_set_ratio
			+ self::WEIGHT_TRIGRAM * $trigram
			+ self::WEIGHT_ATTRIBUTE_BONUS * $attribute_bonus;

		if ( $this->attributes_differ( $row_text, $candidate_title ) ) {
			$score *= self::ATTRIBUTE_MISMATCH_PENALTY;
		}

		return $score;
	}

	/**
	 * Own implementation of fuzzywuzzy-style token_set_ratio: the shared
	 * tokens matter more than each side's leftovers, so a short string
	 * whose tokens are a subset of a longer one still scores near 1.0.
	 *
	 * @param string[] $tokens_a Normalized tokens, row side.
	 * @param string[] $tokens_b Normalized tokens, candidate side.
	 */
	private function token_set_ratio( array $tokens_a, array $tokens_b ): float {
		$set_a = array_unique( $tokens_a );
		$set_b = array_unique( $tokens_b );

		$intersection = array_values( array_intersect( $set_a, $set_b ) );
		$diff_a       = array_values( array_diff( $set_a, $intersection ) );
		$diff_b       = array_values( array_diff( $set_b, $intersection ) );

		sort( $intersection );
		sort( $diff_a );
		sort( $diff_b );

		$intersection_str = implode( ' ', $intersection );
		$combined_a       = trim( $intersection_str . ' ' . implode( ' ', $diff_a ) );
		$combined_b       = trim( $intersection_str . ' ' . implode( ' ', $diff_b ) );

		return max(
			$this->string_ratio( $intersection_str, $combined_a ),
			$this->string_ratio( $intersection_str, $combined_b ),
			$this->string_ratio( $combined_a, $combined_b )
		);
	}

	/**
	 * Levenshtein-based similarity ratio in [0, 1]; 1.0 for identical strings.
	 *
	 * @param string $a First string.
	 * @param string $b Second string.
	 */
	private function string_ratio( string $a, string $b ): float {
		$max_length = max( strlen( $a ), strlen( $b ) );

		if ( 0 === $max_length ) {
			return 1.0;
		}

		return 1.0 - ( levenshtein( $a, $b ) / $max_length );
	}

	/**
	 * Character-trigram Jaccard similarity of the two normalized strings.
	 *
	 * @param string $normalized_a Normalizer::normalize() output, row side.
	 * @param string $normalized_b Normalizer::normalize() output, candidate side.
	 */
	private function trigram_similarity( string $normalized_a, string $normalized_b ): float {
		$trigrams_a = $this->trigrams( $normalized_a );
		$trigrams_b = $this->trigrams( $normalized_b );

		if ( empty( $trigrams_a ) && empty( $trigrams_b ) ) {
			return 1.0;
		}

		if ( empty( $trigrams_a ) || empty( $trigrams_b ) ) {
			return 0.0;
		}

		$intersection = count( array_intersect( $trigrams_a, $trigrams_b ) );
		$union        = count( array_unique( array_merge( $trigrams_a, $trigrams_b ) ) );

		return $union > 0 ? $intersection / $union : 0.0;
	}

	/**
	 * Set of unique, space-padded character trigrams of $text.
	 *
	 * @param string $text Already-normalized text.
	 *
	 * @return string[]
	 */
	private function trigrams( string $text ): array {
		if ( '' === $text ) {
			return array();
		}

		$padded = ' ' . $text . ' ';
		$length = mb_strlen( $padded, 'UTF-8' );

		if ( $length < 3 ) {
			return array( $padded );
		}

		$trigrams = array();

		for ( $i = 0; $i <= $length - 3; $i++ ) {
			$trigrams[] = mb_substr( $padded, $i, 3, 'UTF-8' );
		}

		return array_values( array_unique( $trigrams ) );
	}

	/**
	 * Whether the candidate's brand shows up entirely among the row's
	 * tokens (e.g. brand "Bosch" found in "taladro bosch gsb 13").
	 *
	 * @param string[] $row_tokens      Normalized tokens, row side.
	 * @param string   $candidate_brand Candidate's brand name, raw.
	 */
	private function brand_matches( array $row_tokens, string $candidate_brand ): bool {
		if ( '' === trim( $candidate_brand ) ) {
			return false;
		}

		$brand_tokens = $this->normalizer->tokens( $candidate_brand );

		return empty( array_diff( $brand_tokens, $row_tokens ) );
	}

	/**
	 * Whether a number+unit attribute shared by both sides (e.g. both
	 * mention a quantity in liters) disagrees on the number.
	 *
	 * @param string $row_text        Supplier row description, raw.
	 * @param string $candidate_title Catalog candidate title, raw.
	 */
	private function attributes_differ( string $row_text, string $candidate_title ): bool {
		$attributes_a = $this->extract_attributes( $row_text );
		$attributes_b = $this->extract_attributes( $candidate_title );

		foreach ( $attributes_a as $unit => $numbers_a ) {
			if ( ! isset( $attributes_b[ $unit ] ) ) {
				continue;
			}

			if ( ! $this->numbers_overlap( $numbers_a, $attributes_b[ $unit ] ) ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Whether any number on one side is close enough to a number on the other.
	 *
	 * @param float[] $numbers_a Numbers found for a given unit, row side.
	 * @param float[] $numbers_b Numbers found for the same unit, candidate side.
	 */
	private function numbers_overlap( array $numbers_a, array $numbers_b ): bool {
		foreach ( $numbers_a as $number_a ) {
			foreach ( $numbers_b as $number_b ) {
				if ( abs( $number_a - $number_b ) < 0.0001 ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Finds every "number immediately followed by a recognized unit"
	 * occurrence, grouped by canonical unit. Operates on case/accent-folded
	 * text (not the fully tokenized-and-sorted form), since sorting would
	 * scramble the number-unit adjacency this needs.
	 *
	 * @param string $text Raw row description or candidate title.
	 *
	 * @return array<string, float[]>
	 */
	private function extract_attributes( string $text ): array {
		$folded = $this->normalizer->fold_case( $text );

		$variants = array_keys( self::UNITS );
		usort( $variants, static fn( string $a, string $b ): int => mb_strlen( $b ) <=> mb_strlen( $a ) );

		$alternation = implode( '|', array_map( static fn( string $v ): string => preg_quote( $v, '/' ), $variants ) );

		preg_match_all(
			'/(?<![\p{L}\p{N}])(\d+(?:[.,]\d+)?)\s*(' . $alternation . ')(?![\p{L}\p{N}])/ui',
			$folded,
			$matches,
			PREG_SET_ORDER
		);

		$attributes = array();

		foreach ( $matches as $match ) {
			$number = (float) str_replace( ',', '.', $match[1] );
			$unit   = self::UNITS[ mb_strtolower( $match[2], 'UTF-8' ) ];

			$attributes[ $unit ][] = $number;
		}

		return $attributes;
	}
}
