<?php
/**
 * Normalizes product/supplier text for matching (SPEC §3.1).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Matching;

/**
 * Lowercase -> strip accents -> standardize units -> drop commercial
 * stopwords -> strip punctuation -> collapse spaces -> alphabetically
 * sorted tokens. Pure PHP, no WordPress dependency. Applied identically to
 * supplier row descriptions and catalog product titles so both sides land
 * on the same normalized form for stages 2 and 3 of the match pipeline.
 */
final class Normalizer {

	/**
	 * Commercial noise words dropped once split on whitespace (before
	 * punctuation is stripped, so "c/u" is still intact as one word here).
	 */
	private const STOPWORDS = array( 'oferta', 'nuevo', 'promo', 'unidad', 'c/u' );

	/**
	 * Accented/diacritic characters mapped to their plain ASCII equivalent.
	 * Input is already lowercased by the time this is applied.
	 */
	private const ACCENTS = array(
		'á' => 'a',
		'é' => 'e',
		'í' => 'i',
		'ó' => 'o',
		'ú' => 'u',
		'ü' => 'u',
		'ñ' => 'n',
	);

	/**
	 * Unit spelling variants mapped to their canonical single-letter form.
	 * Order within each variant list matters: longest first, so the
	 * alternation doesn't stop at a shorter prefix (e.g. "lts" before "lt").
	 */
	private const UNITS = array(
		'l' => array( 'lts', 'lt', 'l\.' ),
		'g' => array( 'grs', 'gr' ),
		'm' => array( 'mts', 'mt' ),
	);

	/**
	 * Full normalized string: sorted tokens joined by a single space.
	 *
	 * @param string $text Raw text (supplier row description or product title).
	 */
	public function normalize( string $text ): string {
		return implode( ' ', $this->tokens( $text ) );
	}

	/**
	 * Lowercase + accent stripping only -- the first two steps of the
	 * pipeline, with word order and punctuation left intact. Exposed for
	 * FuzzyScorer's attribute_bonus (SPEC §3.2), which needs number+unit
	 * adjacency ("20 l") that tokens()'s alphabetical sort would scramble.
	 *
	 * @param string $text Raw text.
	 */
	public function fold_case( string $text ): string {
		return strtr( mb_strtolower( $text, 'UTF-8' ), self::ACCENTS );
	}

	/**
	 * The final, alphabetically sorted token list.
	 *
	 * @param string $text Raw text (supplier row description or product title).
	 *
	 * @return string[]
	 */
	public function tokens( string $text ): array {
		$text = $this->fold_case( $text );
		$text = $this->standardize_units( $text );
		$text = $this->remove_stopwords( $text );
		$text = preg_replace( '/[^\p{L}\p{N}\s]/u', ' ', $text ) ?? $text;
		$text = trim( preg_replace( '/\s+/', ' ', $text ) ?? $text );

		if ( '' === $text ) {
			return array();
		}

		$tokens = explode( ' ', $text );
		sort( $tokens );

		return $tokens;
	}

	/**
	 * Standardizes unit spellings (liters/grams/meters) and pack notation.
	 * Handles both a unit glued directly to its number ("20lt") and a unit
	 * standing alone as its own word ("1 lt"); in the glued case a space is
	 * introduced so the number and unit become separate tokens.
	 *
	 * @param string $text Lowercased, accent-stripped text.
	 */
	private function standardize_units( string $text ): string {
		// Pack notation: "x12" / "x 12" -> "pack12". Not preceded by a digit,
		// so a dimension like "6x12" is left alone.
		$text = (string) preg_replace(
			'/(?<![\p{L}\p{N}])x\s*(\d+)/ui',
			'pack$1',
			$text
		);

		foreach ( self::UNITS as $canonical => $variants ) {
			$alternation = implode( '|', $variants );

			// Glued to a preceding number: capture the number, insert a space.
			$text = (string) preg_replace(
				'/(\d+)\s*(?:' . $alternation . ')(?![\p{L}\p{N}])/ui',
				'$1 ' . $canonical,
				$text
			);

			// Standalone word, not part of a longer word on either side.
			$text = (string) preg_replace(
				'/(?<![\p{L}\p{N}])(?:' . $alternation . ')(?![\p{L}\p{N}])/ui',
				$canonical,
				$text
			);
		}

		return $text;
	}

	/**
	 * Drops words that are an exact (whitespace-delimited) match for a
	 * commercial stopword. Run before punctuation stripping so multi-char
	 * stopwords like "c/u" are still recognizable as one word.
	 *
	 * @param string $text Text after unit standardization.
	 */
	private function remove_stopwords( string $text ): string {
		$words = preg_split( '/\s+/', trim( $text ), -1, PREG_SPLIT_NO_EMPTY );

		if ( false === $words ) {
			return $text;
		}

		$words = array_filter(
			$words,
			static fn( string $word ): bool => ! in_array( $word, self::STOPWORDS, true )
		);

		return implode( ' ', $words );
	}
}
