<?php

if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Precomputed catalog vocabulary for the fail-closed local product planner.
 *
 * The configured frontend LLM builds this vocabulary outside the customer
 * request path. Runtime matching is accepted only when every query token is
 * covered by a catalog concept or an explicitly safe conversational phrase.
 */
class Niwa_AI_Frontend_Semantic_Lexicon {
	const OPTION            = 'niwa_ai_frontend_semantic_lexicon';
	const PLAN_CACHE_OPTION = 'niwa_ai_frontend_product_plan_cache';
	const VERSION           = 2;

	const MAX_CONCEPTS          = 260;
	const MAX_ALIASES           = 20;
	const MAX_IGNORABLE_PHRASES = 180;
	const MAX_ACTION_PHRASES    = 80;
	const MAX_CACHED_PLANS      = 160;

	/** Return a complete local plan or a reasoned fail-closed rejection. */
	public static function resolve_query( $query, $previous_plan, $source_hash ) {
		$query_key   = self::normalize_text( $query );
		$source_hash = trim( (string) $source_hash );
		if ( '' === $query_key || '' === $source_hash ) {
			return array( 'complete' => false, 'reason' => 'missing_query_or_catalog_hash' );
		}

		$cached = self::load_cached_plan( $query_key, $source_hash );
		if ( ! empty( $cached['constraints'] ) ) {
			$cached['constraints'] = self::ground_constraints( $query, $cached['constraints'], $source_hash );
			self::learn_attribute_concepts_from_plan( $cached['constraints'], $source_hash );
			return array(
				'complete'         => true,
				'source'           => 'plan_cache',
				'query_mode'       => 'fresh',
				'replaced_fields'  => array(),
				'constraints'      => $cached['constraints'],
				'unresolved_terms' => array(),
				'ambiguous_fields' => array(),
			);
		}

		$lexicon = get_option( self::OPTION, array() );
		if ( ! is_array( $lexicon ) || intval( $lexicon['version'] ?? 0 ) !== self::VERSION ) {
			return array( 'complete' => false, 'reason' => 'lexicon_missing' );
		}
		if ( ! hash_equals( (string) ( $lexicon['source_hash'] ?? '' ), $source_hash ) ) {
			return array( 'complete' => false, 'reason' => 'lexicon_stale' );
		}

		foreach ( (array) ( $lexicon['action_phrases'] ?? array() ) as $phrase ) {
			if ( self::text_has_phrase( $query_key, $phrase ) ) {
				return array( 'complete' => false, 'reason' => 'action_requires_llm', 'matched_action' => (string) $phrase );
			}
		}

		$matches          = array();
		$matched_concepts = array();
		$matched_phrases  = array();
		foreach ( (array) ( $lexicon['concepts'] ?? array() ) as $concept ) {
			if ( ! is_array( $concept ) || empty( $concept['field'] ) || empty( $concept['value'] ) ) {
				continue;
			}
			$field = self::normalize_field( $concept['field'] );
			if ( '' === $field ) {
				continue;
			}
			$matched_alias = '';
			$matched_query_phrase = '';
			foreach ( (array) ( $concept['aliases'] ?? array() ) as $alias ) {
				$alias = self::normalize_text( $alias );
				$query_phrase = self::find_phrase_match( $query_key, $alias, 'gender' === $field );
				if ( '' !== $query_phrase && mb_strlen( $query_phrase ) > mb_strlen( $matched_query_phrase ) ) {
					$matched_alias        = $alias;
					$matched_query_phrase = $query_phrase;
				}
			}
			if ( '' === $matched_alias || '' === $matched_query_phrase ) {
				continue;
			}

			$matched_phrases[ $matched_query_phrase ] = true;
			$concept_key = self::normalize_text( $concept['value'] ?? '' );
			if ( '' !== $concept_key ) {
				if ( ! isset( $matched_concepts[ $field ] ) ) {
					$matched_concepts[ $field ] = array();
				}
				$matched_concepts[ $field ][ $concept_key ] = true;
			}
			$score = mb_strlen( $matched_query_phrase );
			if ( isset( $matches[ $field ] ) && intval( $matches[ $field ]['score'] ) >= $score ) {
				continue;
			}
			$matches[ $field ] = array(
				'score'        => $score,
				'alias'        => $matched_alias,
				'query_phrase' => $matched_query_phrase,
				'concept'      => $concept,
			);
		}

		if ( empty( $matches ) ) {
			return array( 'complete' => false, 'reason' => 'no_catalog_concepts' );
		}
		$ambiguous_fields = array();
		foreach ( $matched_concepts as $field => $concepts ) {
			if ( count( $concepts ) > 1 ) {
				$ambiguous_fields[] = (string) $field;
			}
		}

		$constraints      = array();
		$covered_phrases  = array();
		$replaced_fields  = array();
		$has_identity_concept = false;
		$has_anchor_concept   = false;
		foreach ( $matches as $field => $match ) {
			$concept = $match['concept'];
			$alias        = (string) $match['alias'];
			$query_phrase = (string) $match['query_phrase'];
			$covered_phrases[] = $query_phrase;
			$replaced_fields[] = $field;
			if ( in_array( $field, array( 'department', 'product_type', 'product_family', 'category', 'model', 'sku' ), true ) ) {
				$has_identity_concept = true;
				$has_anchor_concept   = true;
			} elseif ( 'gender' === $field ) {
				$has_anchor_concept = true;
			}

			$equivalents = array_merge(
				array( (string) $concept['value'] ),
				(array) ( $concept['aliases'] ?? array() )
			);
			$equivalents = array_values(
				array_filter(
					array_unique( array_map( 'strval', $equivalents ) ),
					static function ( $term ) use ( $alias ) {
						return '' !== trim( (string) $term ) && self::normalize_text( $term ) !== $alias;
					}
				)
			);

			$constraints[] = array(
				'field'           => $field,
				'label'           => isset( $concept['label'] ) ? (string) $concept['label'] : $field,
				'value'           => $query_phrase,
				'required'        => true,
				'polarity'        => 'include',
				'equivalents'     => array_slice( $equivalents, 0, 12 ),
				'descendants'     => array_slice( (array) ( $concept['descendants'] ?? array() ), 0, 12 ),
				'related'         => array_slice( (array) ( $concept['related'] ?? array() ), 0, 12 ),
				'parents'         => array_slice( (array) ( $concept['parents'] ?? array() ), 0, 12 ),
				'forbidden_terms' => array(),
			);
		}

		$residual = $query_key;
		$removals = array_merge( $covered_phrases, array_keys( $matched_phrases ), (array) ( $lexicon['ignorable_phrases'] ?? array() ) );
		usort(
			$removals,
			static function ( $left, $right ) {
				return mb_strlen( (string) $right ) <=> mb_strlen( (string) $left );
			}
		);
		foreach ( $removals as $phrase ) {
			$phrase = self::normalize_text( $phrase );
			if ( '' !== $phrase ) {
				$residual = preg_replace( '/(?:^|\s)' . preg_quote( $phrase, '/' ) . '(?=$|\s)/u', ' ', $residual );
			}
		}
		$unresolved = array_values( array_filter( preg_split( '/\s+/u', trim( preg_replace( '/\s+/u', ' ', (string) $residual ) ) ) ) );
		$constraints = Niwa_AI_Frontend_Product_Query_Plan::normalize_constraints( $constraints );
		$previous_constraints = is_array( $previous_plan ) ? (array) ( $previous_plan['constraints'] ?? array() ) : array();
		if ( ! empty( $unresolved ) ) {
			return array(
				'complete'         => false,
				'reason'           => 'unresolved_terms',
				'source'           => 'semantic_lexicon_partial',
				'query_mode'       => ! empty( $previous_constraints ) && ! $has_identity_concept ? 'follow_up' : 'fresh',
				'constraints'      => $constraints,
				'replaced_fields'  => array_values( array_unique( $replaced_fields ) ),
				'unresolved_terms' => array_slice( $unresolved, 0, 8 ),
				'ambiguous_fields' => $ambiguous_fields,
			);
		}
		if ( ! empty( $ambiguous_fields ) ) {
			return array(
				'complete'         => false,
				'reason'           => 'ambiguous_concepts',
				'source'           => 'semantic_lexicon_partial',
				'query_mode'       => ! empty( $previous_constraints ) && ! $has_identity_concept ? 'follow_up' : 'fresh',
				'constraints'      => $constraints,
				'replaced_fields'  => array_values( array_unique( $replaced_fields ) ),
				'unresolved_terms' => array(),
				'ambiguous_fields' => $ambiguous_fields,
			);
		}

		if ( ! $has_anchor_concept && empty( $previous_constraints ) ) {
			return array( 'complete' => false, 'reason' => 'attribute_only_without_context' );
		}

		return array(
			'complete'         => true,
			'source'           => 'semantic_lexicon',
			'query_mode'       => ! empty( $previous_constraints ) && ! $has_identity_concept ? 'follow_up' : 'fresh',
			'replaced_fields'  => array_values( array_unique( $replaced_fields ) ),
			'constraints'      => $constraints,
			'unresolved_terms' => array(),
			'ambiguous_fields' => array(),
		);
	}

	/**
	 * Ground every planner constraint against a concept from the same canonical
	 * field. A product type can therefore never donate its relations to gender,
	 * color, material, or any other independent dimension.
	 */
	public static function ground_constraints( $query, $constraints, $source_hash ) {
		$constraints = Niwa_AI_Frontend_Product_Query_Plan::normalize_constraints( $constraints );
		$query_key   = self::normalize_text( $query );
		$source_hash = trim( (string) $source_hash );
		$lexicon     = get_option( self::OPTION, array() );
		if (
			'' === $query_key
			|| '' === $source_hash
			|| ! is_array( $lexicon )
			|| intval( $lexicon['version'] ?? 0 ) !== self::VERSION
			|| ! hash_equals( (string) ( $lexicon['source_hash'] ?? '' ), $source_hash )
		) {
			return $constraints;
		}

		foreach ( $constraints as &$constraint ) {
			$concept = self::matching_constraint_concept(
				$query_key,
				$constraint,
				(array) ( $lexicon['concepts'] ?? array() )
			);
			if ( empty( $concept ) ) {
				continue;
			}

			$literal = self::normalize_text( $constraint['value'] ?? '' );
			$aliases = self::normalize_phrase_list(
				array_merge( array( (string) ( $concept['value'] ?? '' ) ), (array) ( $concept['aliases'] ?? array() ) ),
				self::MAX_ALIASES
			);
			$constraint['equivalents'] = array_values(
				array_filter(
					$aliases,
					static function ( $term ) use ( $literal ) {
						return '' !== self::normalize_text( $term ) && self::normalize_text( $term ) !== $literal;
					}
				)
			);
			$constraint['descendants'] = self::normalize_phrase_list( $concept['descendants'] ?? array(), 16 );
			$constraint['related']     = self::normalize_phrase_list( $concept['related'] ?? array(), 16 );
			$constraint['parents']     = self::normalize_phrase_list( $concept['parents'] ?? array(), 12 );
		}
		unset( $constraint );

		return Niwa_AI_Frontend_Product_Query_Plan::normalize_constraints( $constraints );
	}

	/** Select the best catalog concept without ever crossing field boundaries. */
	private static function matching_constraint_concept( $query_key, $constraint, $concepts ) {
		$field = self::normalize_field( $constraint['field'] ?? '' );
		if ( '' === $field ) {
			return array();
		}

		$constraint_terms = self::normalize_phrase_list(
			array_merge(
				array(
					(string) ( $constraint['value'] ?? '' ),
					(string) ( $constraint['label'] ?? '' ),
				),
				(array) ( $constraint['equivalents'] ?? array() )
			),
			self::MAX_ALIASES
		);
		$allow_short_typo = 'gender' === $field;
		$best             = array();
		$best_score       = 0;

		foreach ( (array) $concepts as $concept ) {
			if ( ! is_array( $concept ) || $field !== self::normalize_field( $concept['field'] ?? '' ) ) {
				continue;
			}
			$aliases = self::normalize_phrase_list(
				array_merge( array( (string) ( $concept['value'] ?? '' ) ), (array) ( $concept['aliases'] ?? array() ) ),
				self::MAX_ALIASES
			);
			$score = 0;
			foreach ( $aliases as $alias ) {
				$alias_key = self::normalize_text( $alias );
				foreach ( $constraint_terms as $term ) {
					$term_key = self::normalize_text( $term );
					if ( '' === $term_key || '' === $alias_key ) {
						continue;
					}
					if ( $term_key === $alias_key ) {
						$score = max( $score, 3000 + mb_strlen( $alias_key ) );
						continue;
					}
					if (
						'' !== self::find_phrase_match( $term_key, $alias_key, $allow_short_typo )
						|| '' !== self::find_phrase_match( $alias_key, $term_key, $allow_short_typo )
					) {
						$score = max( $score, 2000 + min( mb_strlen( $term_key ), mb_strlen( $alias_key ) ) );
					}
				}
				$query_phrase = self::find_phrase_match( $query_key, $alias_key, $allow_short_typo );
				if ( '' !== $query_phrase ) {
					$score = max( $score, 1000 + mb_strlen( $query_phrase ) );
				}
			}
			if ( $score > $best_score ) {
				$best       = $concept;
				$best_score = $score;
			}
		}

		return $best;
	}

	/** Validate and persist an LLM-built lexicon against one catalog version. */
	public static function save( $payload, $source_hash, $model = '' ) {
		$payload     = is_array( $payload ) ? $payload : array();
		$source_hash = trim( (string) $source_hash );
		$concepts    = array();
		$seen        = array();
		foreach ( array_slice( (array) ( $payload['concepts'] ?? array() ), 0, self::MAX_CONCEPTS ) as $concept ) {
			if ( ! is_array( $concept ) ) {
				continue;
			}
			$field = self::normalize_field( $concept['field'] ?? '' );
			$value = self::clean_text( $concept['value'] ?? '', 120 );
			if ( '' === $field || '' === $value ) {
				continue;
			}
			$key = $field . '|' . self::normalize_text( $value );
			if ( isset( $seen[ $key ] ) ) {
				continue;
			}
			$seen[ $key ] = true;
			$aliases = self::normalize_phrase_list(
				array_merge( array( $value ), (array) ( $concept['aliases'] ?? array() ) ),
				self::MAX_ALIASES
			);
			if ( empty( $aliases ) ) {
				continue;
			}
			$concepts[] = array(
				'field'       => $field,
				'label'       => self::clean_text( $concept['label'] ?? $field, 80 ),
				'value'       => $value,
				'aliases'     => $aliases,
				'descendants' => self::normalize_phrase_list( $concept['descendants'] ?? array(), 16 ),
				'related'     => self::normalize_phrase_list( $concept['related'] ?? array(), 16 ),
				'parents'     => self::normalize_phrase_list( $concept['parents'] ?? array(), 12 ),
			);
		}

		if ( '' === $source_hash || empty( $concepts ) ) {
			return array( 'success' => false, 'error' => 'Empty catalog hash or semantic concepts.' );
		}

		$lexicon = array(
			'version'            => self::VERSION,
			'source_hash'        => $source_hash,
			'model'              => sanitize_text_field( (string) $model ),
			'generated_at'       => time(),
			'concepts'           => $concepts,
			'ignorable_phrases'  => self::normalize_phrase_list( $payload['ignorable_phrases'] ?? array(), self::MAX_IGNORABLE_PHRASES ),
			'action_phrases'     => self::normalize_phrase_list( $payload['action_phrases'] ?? array(), self::MAX_ACTION_PHRASES ),
		);
		update_option( self::OPTION, $lexicon, false );

		return array(
			'success'    => true,
			'concepts'   => count( $concepts ),
			'ignorable'  => count( $lexicon['ignorable_phrases'] ),
			'actions'    => count( $lexicon['action_phrases'] ),
			'source_hash'=> $source_hash,
		);
	}

	/** Cache only complete fresh plans; context-dependent follow-ups stay session-local. */
	public static function cache_plan( $query, $plan, $source_hash ) {
		$query_key = self::normalize_text( $query );
		if ( '' === $query_key || 'fresh' !== (string) ( $plan['query_mode'] ?? '' ) || empty( $plan['constraints'] ) ) {
			return false;
		}
		$constraints = Niwa_AI_Frontend_Product_Query_Plan::normalize_constraints( $plan['constraints'] );
		if ( empty( $constraints ) ) {
			return false;
		}

		$cache = get_option( self::PLAN_CACHE_OPTION, array() );
		$cache = is_array( $cache ) ? $cache : array();
		$cache[ hash( 'sha256', $query_key ) ] = array(
			'query_key'   => $query_key,
			'source_hash' => (string) $source_hash,
			'constraints' => $constraints,
			'updated_at'  => time(),
		);
		uasort(
			$cache,
			static function ( $left, $right ) {
				return intval( $right['updated_at'] ?? 0 ) <=> intval( $left['updated_at'] ?? 0 );
			}
		);
		$cache = array_slice( $cache, 0, self::MAX_CACHED_PLANS, true );
		update_option( self::PLAN_CACHE_OPTION, $cache, false );
		self::learn_attribute_concepts_from_plan( $constraints, $source_hash );
		return true;
	}

	/**
	 * Reuse a complete default-LLM plan as catalog-scoped attribute vocabulary.
	 * Candidate facts still require literal structured/description evidence, so
	 * this speeds synonymous wording without turning semantic score into proof.
	 */
	private static function learn_attribute_concepts_from_plan( $constraints, $source_hash ) {
		$lexicon = get_option( self::OPTION, array() );
		if (
			! is_array( $lexicon )
			|| intval( $lexicon['version'] ?? 0 ) !== self::VERSION
			|| ! hash_equals( (string) ( $lexicon['source_hash'] ?? '' ), (string) $source_hash )
		) {
			return false;
		}

		$concepts = is_array( $lexicon['concepts'] ?? null ) ? $lexicon['concepts'] : array();
		$changed  = false;
		foreach ( Niwa_AI_Frontend_Product_Query_Plan::normalize_constraints( $constraints ) as $constraint ) {
			$field = self::normalize_field( $constraint['field'] ?? '' );
			$value = self::clean_text( $constraint['value'] ?? '', 120 );
			if (
				'' === $field
				|| in_array( $field, array( 'department', 'product_type', 'product_family', 'category', 'gender', 'model', 'sku' ), true )
				|| empty( $constraint['required'] )
				|| 'exclude' === (string) ( $constraint['polarity'] ?? 'include' )
				|| mb_strlen( self::normalize_text( $value ) ) < 3
				|| ! preg_match( '/\p{L}/u', $value )
			) {
				continue;
			}

			$aliases = self::normalize_phrase_list(
				array_merge( array( $value ), (array) ( $constraint['equivalents'] ?? array() ) ),
				self::MAX_ALIASES
			);
			if ( empty( $aliases ) ) {
				continue;
			}
			$alias_keys = array_fill_keys( array_map( array( __CLASS__, 'normalize_text' ), $aliases ), true );
			$match_index = -1;
			foreach ( $concepts as $index => $concept ) {
				if ( ! is_array( $concept ) || $field !== self::normalize_field( $concept['field'] ?? '' ) ) {
					continue;
				}
				$existing_terms = array_merge( array( (string) ( $concept['value'] ?? '' ) ), (array) ( $concept['aliases'] ?? array() ) );
				foreach ( $existing_terms as $existing_term ) {
					if ( isset( $alias_keys[ self::normalize_text( $existing_term ) ] ) ) {
						$match_index = intval( $index );
						break 2;
					}
				}
			}

			$learned = array(
				'field'       => $field,
				'label'       => self::clean_text( $constraint['label'] ?? $field, 80 ),
				'value'       => $value,
				'aliases'     => $aliases,
				'descendants' => self::normalize_phrase_list( $constraint['descendants'] ?? array(), 16 ),
				'related'     => self::normalize_phrase_list( $constraint['related'] ?? array(), 16 ),
				'parents'     => self::normalize_phrase_list( $constraint['parents'] ?? array(), 12 ),
			);
			if ( $match_index >= 0 ) {
				$existing = $concepts[ $match_index ];
				$concept_changed = false;
				foreach ( array( 'aliases' => self::MAX_ALIASES, 'descendants' => 16, 'related' => 16, 'parents' => 12 ) as $key => $limit ) {
					$merged_terms = self::normalize_phrase_list(
						array_merge( (array) ( $existing[ $key ] ?? array() ), (array) $learned[ $key ] ),
						$limit
					);
					if ( $merged_terms !== (array) ( $existing[ $key ] ?? array() ) ) {
						$concepts[ $match_index ][ $key ] = $merged_terms;
						$concept_changed = true;
					}
				}
				$changed = $changed || $concept_changed;
			} elseif ( count( $concepts ) < self::MAX_CONCEPTS ) {
				$concepts[] = $learned;
				$changed    = true;
			}
		}

		if ( $changed ) {
			$lexicon['concepts'] = array_values( $concepts );
			update_option( self::OPTION, $lexicon, false );
		}
		return $changed;
	}

	public static function get_status() {
		$lexicon = get_option( self::OPTION, array() );
		return is_array( $lexicon )
			? array(
				'version'      => intval( $lexicon['version'] ?? 0 ),
				'source_hash'  => (string) ( $lexicon['source_hash'] ?? '' ),
				'model'        => (string) ( $lexicon['model'] ?? '' ),
				'generated_at' => intval( $lexicon['generated_at'] ?? 0 ),
				'concepts'     => count( (array) ( $lexicon['concepts'] ?? array() ) ),
			) : array();
	}

	private static function load_cached_plan( $query_key, $source_hash ) {
		$cache = get_option( self::PLAN_CACHE_OPTION, array() );
		$entry = is_array( $cache ) ? ( $cache[ hash( 'sha256', $query_key ) ] ?? array() ) : array();
		if ( ! is_array( $entry ) || ! hash_equals( (string) ( $entry['source_hash'] ?? '' ), (string) $source_hash ) ) {
			return array();
		}
		if ( ! hash_equals( (string) ( $entry['query_key'] ?? '' ), $query_key ) ) {
			return array();
		}
		$entry['constraints'] = Niwa_AI_Frontend_Product_Query_Plan::normalize_constraints( $entry['constraints'] ?? array() );
		return $entry;
	}

	private static function normalize_phrase_list( $phrases, $limit ) {
		$out  = array();
		$seen = array();
		foreach ( array_slice( (array) $phrases, 0, max( 1, intval( $limit ) * 2 ) ) as $phrase ) {
			$phrase = self::clean_text( $phrase, 120 );
			$key    = self::normalize_text( $phrase );
			if ( '' !== $key && ! isset( $seen[ $key ] ) ) {
				$seen[ $key ] = true;
				$out[]        = $phrase;
				if ( count( $out ) >= intval( $limit ) ) {
					break;
				}
			}
		}
		return $out;
	}

	private static function text_has_phrase( $haystack, $phrase ) {
		return '' !== self::find_phrase_match( $haystack, $phrase );
	}

	/** Return the actual query phrase matched by exact wording, inflection, or a close long-word typo. */
	private static function find_phrase_match( $haystack, $phrase, $allow_short_typo = false ) {
		$haystack = self::normalize_text( $haystack );
		$phrase   = self::normalize_text( $phrase );
		if ( '' === $haystack || '' === $phrase ) {
			return '';
		}
		if ( 1 === preg_match( '/(?:^|\s)(' . preg_quote( $phrase, '/' ) . ')(?=$|\s)/u', $haystack, $exact ) ) {
			return isset( $exact[1] ) ? (string) $exact[1] : $phrase;
		}

		$haystack_tokens = array_values( array_filter( (array) preg_split( '/\s+/u', $haystack ) ) );
		$phrase_tokens   = array_values( array_filter( (array) preg_split( '/\s+/u', $phrase ) ) );
		$phrase_count    = count( $phrase_tokens );
		if ( 0 === $phrase_count || count( $haystack_tokens ) < $phrase_count ) {
			return '';
		}

		$last_start = count( $haystack_tokens ) - $phrase_count;
		for ( $start = 0; $start <= $last_start; ++$start ) {
			$matches = true;
			foreach ( $phrase_tokens as $offset => $phrase_token ) {
				if ( ! self::tokens_are_close( $haystack_tokens[ $start + $offset ], $phrase_token, $allow_short_typo ) ) {
					$matches = false;
					break;
				}
			}
			if ( $matches ) {
				return implode( ' ', array_slice( $haystack_tokens, $start, $phrase_count ) );
			}
		}

		return '';
	}

	private static function tokens_are_close( $candidate_token, $phrase_token, $allow_short_typo = false ) {
		$candidate_token = (string) $candidate_token;
		$phrase_token    = (string) $phrase_token;
		if ( $candidate_token === $phrase_token ) {
			return true;
		}
		if ( preg_match( '/\p{N}/u', $candidate_token . $phrase_token ) ) {
			return false;
		}

		$phrase_length    = mb_strlen( $phrase_token );
		$candidate_length = mb_strlen( $candidate_token );
		if ( $phrase_length >= 4 ) {
			$root = mb_substr( $phrase_token, 0, $phrase_length - 1 );
			$root_length = mb_strlen( $root );
			if ( 0 === mb_strpos( $candidate_token, $root ) && $candidate_length >= $root_length && $candidate_length <= $root_length + 3 ) {
				return true;
			}
		}
		$max_edits = min( $candidate_length, $phrase_length ) >= 7 ? 2 : 1;
		$minimum_length = $allow_short_typo ? 4 : 5;
		if ( min( $candidate_length, $phrase_length ) < $minimum_length || abs( $candidate_length - $phrase_length ) > $max_edits ) {
			return false;
		}

		return self::unicode_damerau_distance( $candidate_token, $phrase_token ) <= $max_edits;
	}

	/** Unicode-safe typo distance with adjacent transposition support. */
	private static function unicode_damerau_distance( $left, $right ) {
		$left     = preg_split( '//u', (string) $left, -1, PREG_SPLIT_NO_EMPTY );
		$right    = preg_split( '//u', (string) $right, -1, PREG_SPLIT_NO_EMPTY );
		$rows     = count( $left );
		$cols     = count( $right );
		$distance = array();
		for ( $row = 0; $row <= $rows; ++$row ) {
			$distance[ $row ]    = array_fill( 0, $cols + 1, 0 );
			$distance[ $row ][0] = $row;
		}
		for ( $col = 0; $col <= $cols; ++$col ) {
			$distance[0][ $col ] = $col;
		}
		for ( $row = 1; $row <= $rows; ++$row ) {
			for ( $col = 1; $col <= $cols; ++$col ) {
				$cost = $left[ $row - 1 ] === $right[ $col - 1 ] ? 0 : 1;
				$distance[ $row ][ $col ] = min(
					$distance[ $row - 1 ][ $col ] + 1,
					$distance[ $row ][ $col - 1 ] + 1,
					$distance[ $row - 1 ][ $col - 1 ] + $cost
				);
				if (
					$row > 1
					&& $col > 1
					&& $left[ $row - 1 ] === $right[ $col - 2 ]
					&& $left[ $row - 2 ] === $right[ $col - 1 ]
				) {
					$distance[ $row ][ $col ] = min( $distance[ $row ][ $col ], $distance[ $row - 2 ][ $col - 2 ] + 1 );
				}
			}
		}
		return $distance[ $rows ][ $cols ];
	}

	private static function normalize_field( $field ) {
		$field = strtolower( trim( (string) $field ) );
		$field = preg_replace( '/[^a-z0-9_]+/', '_', str_replace( array( '-', ' ' ), '_', $field ) );
		return trim( (string) $field, '_' );
	}

	private static function clean_text( $value, $limit ) {
		$value = trim( wp_strip_all_tags( (string) $value ) );
		return mb_strlen( $value ) > intval( $limit ) ? mb_substr( $value, 0, intval( $limit ) ) : $value;
	}

	private static function normalize_text( $value ) {
		$value = html_entity_decode( mb_strtolower( (string) $value, 'UTF-8' ), ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		if ( function_exists( 'remove_accents' ) ) {
			$value = remove_accents( $value );
		}
		$value = preg_replace( "/(?<=\p{L})['’ʼ](?=\p{L})/u", '', $value );
		$value = preg_replace( '/[^\p{L}\p{N}\s]+/u', ' ', $value );
		return trim( preg_replace( '/\s+/u', ' ', (string) $value ) );
	}
}
