<?php
/**
 * Unicode-safe StoreIndex query tokenizer.
 *
 * @package AIPT\Api\StoreIndex\Util
 */

namespace AIPT\Api\StoreIndex\Util;

/**
 * QueryTokenizer class.
 */
class QueryTokenizer {

	/**
	 * Existing minimum token length guard, measured in bytes like the legacy code.
	 */
	private const MIN_TOKEN_BYTES = 3;

	/**
	 * Whether the mbstring fallback warning was already emitted.
	 *
	 * @var bool
	 */
	private static $has_logged_mbstring_missing = false;

	/**
	 * Normalize a query or indexed text for case-insensitive matching.
	 *
	 * @param string $text Text to normalize.
	 * @return string Normalized text.
	 */
	public static function normalize( string $text ): string {
		$text = trim( $text );
		if ( $text === '' ) {
			return '';
		}

		if ( function_exists( 'mb_strtolower' ) ) {
			$normalized = mb_strtolower( $text, 'UTF-8' );
		} else {
			self::logMissingMbstring();
			$normalized = strtolower( $text );
		}

		return self::normalizeTurkishDottedI( $normalized );
	}

	/**
	 * Tokenize a query into Unicode letter/number search terms.
	 *
	 * @param string $query Search query.
	 * @return string[] Token list.
	 */
	public static function tokenize( string $query ): array {
		$normalized = self::normalize( $query );
		if ( $normalized === '' ) {
			return array();
		}

		$tokens = preg_split( '/[^\p{L}\p{N}]+/u', $normalized, -1, PREG_SPLIT_NO_EMPTY );
		if ( ! is_array( $tokens ) ) {
			return array();
		}

		return array_values( array_filter( $tokens, array( self::class, 'isSearchToken' ) ) );
	}

	/**
	 * Keep legacy min-length behaviour while preserving short SKU/brand code parts.
	 *
	 * Length is measured in characters (mb_strlen) so a 2-char Turkish/Korean
	 * token isn't reported as 4-6 bytes and rejected. The short-token allow-list
	 * accepts any 2-character Unicode letter/digit pair.
	 *
	 * @param string $token Token to evaluate.
	 * @return bool True when the token should be used for search.
	 */
	private static function isSearchToken( string $token ): bool {

		$length = function_exists( 'mb_strlen' )
			? mb_strlen( $token, 'UTF-8' )
			: (int) preg_match_all( '/./u', $token );

		if ( $length >= self::MIN_TOKEN_BYTES ) {
			return true;
		}

		return $length === 2 && preg_match( '/^[\p{L}\p{N}]{2}$/u', $token ) === 1;
	}

	/**
	 * Stem an English plural query token to its singular form (best-effort).
	 *
	 * Snowball-lite: two regex rules that cover the empirical "no_keyword_overlap"
	 * failures reported by customer Quan (Bad Backs Australia, 2026-06-13) plus
	 * the common shop-talk plural patterns.
	 *
	 *   ES-rule  "es" after sibilant cluster (s/x/z/ch/sh) -> strip "es"
	 *            boxes  -> box, classes -> class, dishes -> dish
	 *   S-rule   "s" (and not "ss") at end -> strip "s"
	 *            chairs -> chair, hoodies -> hoodie, tools -> tool
	 *
	 * Called at score time on QUERY tokens only — never on stored corpus
	 * tokens — so no re-index is required for the existing catalog.
	 *
	 * INTENTIONAL LIMITATION: y-stem plurals (accessories -> accessory,
	 * categories -> category) are NOT algorithmically distinguishable from
	 * e-stem plurals (hoodies -> hoodie) without a dictionary. Both have a
	 * consonant before "ies". We deliberately fall through to the S-rule
	 * for all "ies" words, yielding "accessorie" for "accessories" — wrong
	 * as English, but harmless in production: the category-name scoring
	 * fix in IndexConsumer::scoreProduct already catches "accessories"
	 * against the "Accessories" category without needing stemming. See
	 * IndexConsumerCategoryScoringTest + IndexConsumerStemmingTest.
	 *
	 * Filter `aipt_storeindex_keyword_stem` (default true): the safety
	 * valve for non-English stores (Turkish, German, Japanese ...) where
	 * the English suffix regex would corrupt matches. Filter receives
	 * `(bool $apply, string $token)` and returning false short-circuits
	 * stemming to a verbatim return.
	 *
	 * @since 2.6.2
	 * @param string $token Raw lowercase query token.
	 * @return string Stemmed token, or original if no rule applies.
	 */
	public static function stem( string $token ): string {
		if ( ! apply_filters( 'aipt_storeindex_keyword_stem', true, $token ) ) {
			return $token;
		}
		if ( strlen( $token ) < 4 ) {
			return $token;
		}

		if ( substr( $token, -2 ) === 'es' ) {
			$stem       = substr( $token, 0, -2 );
			$stem_last  = substr( $stem, -1 );
			$stem_last2 = substr( $stem, -2 );
			if ( in_array( $stem_last, array( 's', 'x', 'z' ), true ) || in_array( $stem_last2, array( 'ch', 'sh' ), true ) ) {
				return $stem;
			}
		}

		if ( substr( $token, -1 ) === 's' && substr( $token, -2 ) !== 'ss' ) {
			return substr( $token, 0, -1 );
		}

		return $token;
	}

	/**
	 * Normalize Turkish capital dotted I to plain ASCII i after lowercase folding.
	 *
	 * @param string $text Lowercased text.
	 * @return string Text with Turkish dotted-I combining marks removed.
	 */
	private static function normalizeTurkishDottedI( string $text ): string {
		$text = str_replace( "i\xCC\x87", 'i', $text );
		return str_replace( "\xC4\xB0", 'i', $text );
	}

	/**
	 * Log mbstring absence once per request.
	 *
	 * @return void
	 */
	private static function logMissingMbstring(): void {
		if ( self::$has_logged_mbstring_missing ) {
			return;
		}

		self::$has_logged_mbstring_missing = true;

	}
}
