<?php

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

/**
 * Domain-agnostic product constraint plan and deterministic match classifier.
 *
 * The LLM describes the visitor intent and semantic relations. This class is
 * the local policy boundary: it validates that plan, compares every candidate
 * with catalog evidence, and guarantees class-first ordering and progressive
 * relaxation. Retrieval similarity is used only inside one match class.
 */
class Niwa_AI_Frontend_Product_Query_Plan {
	const EXACT_VERIFIED    = 'exact_verified';
	const EXACT_INFERRED    = 'exact_inferred';
	const CLOSE_ALTERNATIVE = 'close_alternative';
	const BROAD_ALTERNATIVE = 'broad_alternative';
	const REJECTED          = 'rejected';

	const MAX_CONSTRAINTS = 12;
	const MAX_TERMS       = 16;

	/**
	 * Validate a model-provided, catalog-independent constraint plan.
	 *
	 * Literal values are eligible for verified matches. Equivalent terms and
	 * descendants satisfy the requirement but remain inferred. Related values
	 * and parent concepts are discovery-only relaxation signals.
	 */
	public static function normalize_constraints( $constraints ) {
		$out  = array();
		$seen = array();

		foreach ( array_slice( (array) $constraints, 0, self::MAX_CONSTRAINTS ) as $index => $constraint ) {
			if ( ! is_array( $constraint ) ) {
				continue;
			}

			$field = self::normalize_key( isset( $constraint['field'] ) ? $constraint['field'] : '' );
			$field = 'pack_weight' === $field ? 'weight' : $field;
			$value = self::clean_text( isset( $constraint['value'] ) ? $constraint['value'] : '', 180 );
			if ( '' === $field ) {
				$field = 'constraint_' . ( intval( $index ) + 1 );
			}
			$has_numeric_rule = ( array_key_exists( 'min', $constraint ) && is_numeric( $constraint['min'] ) )
				|| ( array_key_exists( 'max', $constraint ) && is_numeric( $constraint['max'] ) )
				|| ( array_key_exists( 'target', $constraint ) && is_numeric( $constraint['target'] ) );
			if ( '' === $value && ! $has_numeric_rule ) {
				continue;
			}

			$key = $field;
			if ( isset( $seen[ $key ] ) ) {
				$suffix = 2;
				while ( isset( $seen[ $key . '_' . $suffix ] ) ) {
					++$suffix;
				}
				$key .= '_' . $suffix;
			}
			$seen[ $key ] = true;

			$polarity = isset( $constraint['polarity'] ) ? self::normalize_key( $constraint['polarity'] ) : 'include';
			if ( ! in_array( $polarity, array( 'include', 'exclude' ), true ) ) {
				$polarity = 'include';
			}
			$min    = self::nullable_number( isset( $constraint['min'] ) ? $constraint['min'] : null );
			$max    = self::nullable_number( isset( $constraint['max'] ) ? $constraint['max'] : null );
			$target = self::nullable_number( isset( $constraint['target'] ) ? $constraint['target'] : null );
			$unit   = self::clean_text( isset( $constraint['unit'] ) ? $constraint['unit'] : '', 32 );
			if ( null === $min && null === $max && null === $target && preg_match( '/^\s*(\d+(?:[.,]\d+)?)\s*(\p{L}{1,12})\s*$/u', $value, $numeric ) ) {
				$target = floatval( str_replace( ',', '.', $numeric[1] ) );
				$unit   = $numeric[2];
			}

			$out[] = array(
				'key'             => $key,
				'field'           => $field,
				'label'           => self::clean_text( isset( $constraint['label'] ) ? $constraint['label'] : $field, 120 ),
				'value'           => $value,
				'required'        => ! array_key_exists( 'required', $constraint ) || ! empty( $constraint['required'] ),
				'polarity'        => $polarity,
				'equivalents'     => self::normalize_term_list( isset( $constraint['equivalents'] ) ? $constraint['equivalents'] : array() ),
				'descendants'     => self::normalize_term_list( isset( $constraint['descendants'] ) ? $constraint['descendants'] : array() ),
				'related'         => self::normalize_term_list( isset( $constraint['related'] ) ? $constraint['related'] : array() ),
				'parents'         => self::normalize_term_list( isset( $constraint['parents'] ) ? $constraint['parents'] : array() ),
				'forbidden_terms' => self::normalize_term_list( isset( $constraint['forbidden_terms'] ) ? $constraint['forbidden_terms'] : array() ),
				'min'             => $min,
				'max'             => $max,
				'target'          => $target,
				'tolerance'       => max( 0.0, floatval( isset( $constraint['tolerance'] ) ? $constraint['tolerance'] : 0 ) ),
				'unit'            => $unit,
			);
		}

		return $out;
	}

	/**
	 * Test one normalized requirement against a bounded set of catalog values.
	 * This is the shared language-neutral contract used by candidate validation
	 * and final variation-image selection.
	 */
	public static function constraint_matches_values( $values, $constraint ) {
		$constraints = self::normalize_constraints( array( $constraint ) );
		if ( empty( $constraints ) ) {
			return false;
		}

		return '' !== self::positive_constraint_status(
			implode( ' ', array_filter( array_map( 'strval', (array) $values ) ) ),
			$constraints[0]
		);
	}

	/**
	 * Build a small deterministic set of discovery queries from the semantic
	 * relations in a validated plan. One requirement is substituted at a time,
	 * so batch retrieval can find a related catalog term without collapsing all
	 * requirements into an unconstrained synonym soup.
	 */
	public static function build_search_variants( $constraints, $limit = 4 ) {
		$constraints = self::normalize_constraints( $constraints );
		$limit       = max( 0, min( 8, intval( $limit ) ) );
		$base        = array();
		foreach ( $constraints as $constraint ) {
			if ( 'include' === $constraint['polarity'] && '' !== $constraint['value'] ) {
				$base[ $constraint['key'] ] = $constraint['value'];
			}
		}

		$out  = array();
		$seen = array();
		foreach ( $constraints as $constraint ) {
			if ( 'include' !== $constraint['polarity'] || ! isset( $base[ $constraint['key'] ] ) ) {
				continue;
			}
			$alternatives = array_merge(
				$constraint['equivalents'],
				$constraint['descendants'],
				$constraint['related'],
				$constraint['parents']
			);
			foreach ( $alternatives as $alternative ) {
				$parts                         = $base;
				$parts[ $constraint['key'] ]   = $alternative;
				$query                         = trim( implode( ' ', array_values( $parts ) ) );
				$query_key                     = self::normalize_text( $query );
				if ( '' === $query_key || isset( $seen[ $query_key ] ) ) {
					continue;
				}
				$seen[ $query_key ] = true;
				$out[]              = $query;
				if ( count( $out ) >= $limit ) {
					return $out;
				}
			}
		}

		return $out;
	}

	/**
	 * Classify, sort, and mark card eligibility for compact product candidates.
	 */
	public static function classify_candidates( $candidates, $constraints ) {
		$constraints = self::normalize_constraints( $constraints );
		$has_constraints = ! empty( $constraints );
		$classified  = array();

		foreach ( (array) $candidates as $rank => $candidate ) {
			if ( ! is_array( $candidate ) ) {
				continue;
			}
			$type = isset( $candidate['type'] ) ? (string) $candidate['type'] : ( isset( $candidate['post_type'] ) ? (string) $candidate['post_type'] : 'product' );
			if ( '' !== $type && 'product' !== $type ) {
				$candidate['match_class']       = self::REJECTED;
				$candidate['card_eligible']     = false;
				$candidate['constraint_evidence'] = array();
				$classified[] = $candidate;
				continue;
			}

			$candidate['_retrieval_rank'] = intval( $rank );
			$candidate = self::classify_candidate( $candidate, $constraints );
			$classified[] = $candidate;
		}

		usort( $classified, array( __CLASS__, 'compare_candidates' ) );
		/* Unclassified semantic discovery must never create product cards. */
		$best_class = $has_constraints ? self::best_public_class( $classified ) : self::EXACT_VERIFIED;
		foreach ( $classified as &$candidate ) {
			$candidate['card_eligible'] = '' !== $best_class
				&& isset( $candidate['match_class'] )
				&& $best_class === $candidate['match_class'];
		}
		unset( $candidate );

		return $classified;
	}

	public static function classify_candidate( $candidate, $constraints ) {
		$constraints = self::normalize_constraints( $constraints );
		$evidence    = array();
		$deficits    = 0;
		$has_inferred = false;
		$has_parent   = false;
		$has_core_gap = false;
		$rejected     = false;

		if ( empty( $constraints ) ) {
			$is_exact_identity = ! empty( $candidate['exact_match'] ) || ! empty( $candidate['_exact_match'] );
			$candidate['match_class']        = $is_exact_identity ? self::EXACT_VERIFIED : self::BROAD_ALTERNATIVE;
			$candidate['disclosure_required'] = ! $is_exact_identity;
			$candidate['constraint_evidence'] = array();
			$candidate['matched_constraints'] = array();
			$candidate['missing_constraints'] = array();
			$candidate['conflicting_constraints'] = array();
			return $candidate;
		}

		foreach ( $constraints as $constraint ) {
			$evidence[] = self::match_constraint( $candidate, $constraint );
		}

		self::enforce_available_variation_combination( $candidate, $constraints, $evidence );

		foreach ( $constraints as $index => $constraint ) {
			$result = isset( $evidence[ $index ] ) ? $evidence[ $index ] : array( 'status' => 'missing', 'rejected' => false );
			if ( ! empty( $result['rejected'] ) ) {
				$rejected = true;
			}
			if ( empty( $constraint['required'] ) ) {
				continue;
			}
			if ( in_array( $result['status'], array( 'equivalent', 'descendant', 'description_literal', 'description_equivalent', 'description_descendant' ), true ) ) {
				$has_inferred = true;
				continue;
			}
			if ( 'literal' === $result['status'] ) {
				continue;
			}
			++$deficits;
			if ( 'parent' === $result['status'] ) {
				$has_parent = true;
			}
			if ( self::is_core_field( $constraint['field'] ) ) {
				$has_core_gap = true;
			}
		}

		if ( $rejected ) {
			$match_class = self::REJECTED;
		} elseif ( 0 === $deficits ) {
			$match_class = $has_inferred ? self::EXACT_INFERRED : self::EXACT_VERIFIED;
		} elseif ( $deficits >= 2 || $has_parent || $has_core_gap ) {
			$match_class = self::BROAD_ALTERNATIVE;
		} else {
			$match_class = self::CLOSE_ALTERNATIVE;
		}

		$candidate['match_class']         = $match_class;
		$candidate['disclosure_required'] = in_array( $match_class, array( self::CLOSE_ALTERNATIVE, self::BROAD_ALTERNATIVE ), true );
		$candidate['constraint_evidence'] = self::compact_constraint_evidence( $evidence );
		$candidate['matched_constraints'] = self::constraint_keys_by_status( $evidence, array( 'literal', 'equivalent', 'descendant', 'description_literal', 'description_equivalent', 'description_descendant' ) );
		$candidate['missing_constraints'] = self::constraint_keys_by_status( $evidence, array( 'missing', 'related', 'parent' ) );
		$candidate['conflicting_constraints'] = self::constraint_keys_by_status( $evidence, array( 'forbidden', 'excluded_value' ) );

		return $candidate;
	}

	public static function class_priority( $match_class ) {
		$priorities = array(
			self::EXACT_VERIFIED    => 0,
			self::EXACT_INFERRED    => 1,
			self::CLOSE_ALTERNATIVE => 2,
			self::BROAD_ALTERNATIVE => 3,
			self::REJECTED          => 4,
		);
		return isset( $priorities[ $match_class ] ) ? $priorities[ $match_class ] : 5;
	}

	private static function match_constraint( $candidate, $constraint ) {
		$haystack = self::candidate_evidence_text( $candidate, $constraint['field'], false );
		$discovery_haystack = self::candidate_evidence_text( $candidate, $constraint['field'], true );
		$description_haystack = self::candidate_description_evidence_text( $candidate );
		$variation_group = self::variation_group_for_constraint( $candidate, $constraint );
		if ( '' !== $variation_group ) {
			$availability = $candidate['_variation_availability'];
			$group        = $availability['groups'][ $variation_group ];
			$haystack     = self::normalize_text( implode( ' ', isset( $group['available_values'] ) ? (array) $group['available_values'] : array() ) );
			/*
			 * Once a requested value is identified as a variation axis, neither
			 * the stale parent option list nor product prose may promote it back to
			 * an exact fact. Only values on purchasable variations are eligible.
			 */
			$discovery_haystack = $haystack;
			$description_haystack = '';
		}
		$literal_terms = array_values( array_filter( array( $constraint['value'] ) ) );
		$result = array(
			'key'      => $constraint['key'],
			'field'    => $constraint['field'],
			'value'    => $constraint['value'],
			'required' => ! empty( $constraint['required'] ),
			'status'   => 'missing',
			'matched_term' => '',
			'source_scope' => '' !== $variation_group ? 'available_variation' : self::source_scope_for_field( $constraint['field'] ),
			'rejected' => false,
			'_variation_group' => $variation_group,
		);

		$forbidden = self::first_matching_term( $haystack, $constraint['forbidden_terms'] );
		if ( '' !== $forbidden && ! self::text_has_negated_term( $haystack, $forbidden ) ) {
			$result['status']       = 'forbidden';
			$result['matched_term'] = $forbidden;
			$result['rejected']     = true;
			return $result;
		}

		if ( self::numeric_constraint_matches( $haystack, $constraint ) ) {
			$result['status']       = 'literal';
			$result['matched_term'] = self::numeric_requirement_label( $constraint );
			return $result;
		}
		if ( null !== $constraint['min'] || null !== $constraint['max'] || null !== $constraint['target'] ) {
			return $result;
		}

		$literal = self::first_matching_term( $haystack, $literal_terms );
		if ( 'exclude' === $constraint['polarity'] ) {
			$equivalent = self::first_matching_term( $haystack, $constraint['equivalents'] );
			$matched    = '' !== $literal ? $literal : $equivalent;
			$description_matched = '';
			if ( '' === $matched ) {
				$description_matched = self::first_matching_term( $description_haystack, array_merge( $literal_terms, $constraint['equivalents'] ) );
				$matched             = $description_matched;
			}
			if ( '' !== $matched && self::text_has_negated_term( $haystack, $matched ) ) {
				$result['status']       = '' !== $literal ? 'literal' : 'equivalent';
				$result['matched_term'] = $matched;
			} elseif ( '' !== $description_matched && self::text_has_negated_term( $description_haystack, $description_matched ) ) {
				$result['status']       = 'description_equivalent';
				$result['matched_term'] = $description_matched;
				$result['source_scope'] = 'catalog_description';
			} elseif ( '' !== $matched ) {
				$result['status']       = 'excluded_value';
				$result['matched_term'] = $matched;
				$result['source_scope'] = '' !== $description_matched ? 'catalog_description' : $result['source_scope'];
				$result['rejected']     = true;
			}
			return $result;
		}

		if ( '' !== $literal ) {
			if ( self::text_has_negated_term( $haystack, $literal ) ) {
				$result['status']       = 'forbidden';
				$result['matched_term'] = $literal;
				$result['rejected']     = true;
				return $result;
			}
			$result['status']       = 'literal';
			$result['matched_term'] = $literal;
			return $result;
		}

		$equivalent = self::first_matching_term( $haystack, $constraint['equivalents'] );
		if ( '' !== $equivalent ) {
			if ( self::text_has_negated_term( $haystack, $equivalent ) ) {
				$result['status']       = 'forbidden';
				$result['matched_term'] = $equivalent;
				$result['rejected']     = true;
				return $result;
			}
			$result['status']       = 'equivalent';
			$result['matched_term'] = $equivalent;
			return $result;
		}

		$descendant = self::first_matching_term( $haystack, $constraint['descendants'] );
		if ( '' !== $descendant ) {
			if ( self::text_has_negated_term( $haystack, $descendant ) ) {
				$result['status']       = 'forbidden';
				$result['matched_term'] = $descendant;
				$result['rejected']     = true;
				return $result;
			}
			$result['status']       = 'descendant';
			$result['matched_term'] = $descendant;
			return $result;
		}

		/*
		 * Product descriptions are factual catalog evidence, but not as strong as
		 * a normalized attribute or category. A literal/equivalent/descendant
		 * phrase in that bounded evidence may therefore satisfy the constraint as
		 * exact_inferred. Semantic score alone never reaches this branch.
		 */
		$description_groups = array(
			'description_literal'    => $literal_terms,
			'description_equivalent' => $constraint['equivalents'],
			'description_descendant' => $constraint['descendants'],
		);
		foreach ( $description_groups as $description_status => $description_terms ) {
			$description_match = self::first_matching_term( $description_haystack, $description_terms );
			if ( '' === $description_match ) {
				continue;
			}
			if ( self::text_has_negated_term( $description_haystack, $description_match ) ) {
				$result['status']       = 'forbidden';
				$result['matched_term'] = $description_match;
				$result['source_scope'] = 'catalog_description';
				$result['rejected']     = true;
				return $result;
			}
			$result['status']       = $description_status;
			$result['matched_term'] = $description_match;
			$result['source_scope'] = 'catalog_description';
			return $result;
		}

		$related = self::first_matching_term( $haystack, $constraint['related'] );
		if ( '' !== $related ) {
			$result['status']       = 'related';
			$result['matched_term'] = $related;
			return $result;
		}

		$parent = self::first_matching_term( $haystack, $constraint['parents'] );
		if ( '' !== $parent ) {
			$result['status']       = 'parent';
			$result['matched_term'] = $parent;
			return $result;
		}

		/*
		 * Unknown/custom fields are verified only from structured attributes.
		 * A literal phrase found solely in title, tags, or prose is useful for
		 * discovery, but remains an alternative rather than becoming a fact.
		 */
		if ( $discovery_haystack !== $haystack ) {
			$discovery_terms = array_merge( $literal_terms, $constraint['equivalents'], $constraint['descendants'], $constraint['related'], $constraint['parents'] );
			foreach ( $discovery_terms as $term ) {
				if ( self::text_has_negated_term( $discovery_haystack, $term ) ) {
					$result['status']       = 'forbidden';
					$result['matched_term'] = (string) $term;
					$result['source_scope'] = 'discovery_evidence';
					$result['rejected']     = true;
					return $result;
				}
			}
			foreach ( $discovery_terms as $term ) {
				if ( self::text_has_term( $discovery_haystack, $term ) ) {
					$result['status']       = 'related';
					$result['matched_term'] = (string) $term;
					$result['source_scope'] = 'discovery_evidence';
					break;
				}
			}
		}

		return $result;
	}

	/**
	 * Find the variation axis whose declared values prove what the constraint
	 * refers to. Product-level attributes which are not variation axes continue
	 * to use normal parent evidence.
	 */
	private static function variation_group_for_constraint( $candidate, $constraint ) {
		if ( 'include' !== $constraint['polarity'] || empty( $candidate['_variation_availability']['complete'] ) ) {
			return '';
		}
		$groups = isset( $candidate['_variation_availability']['groups'] ) ? $candidate['_variation_availability']['groups'] : array();
		foreach ( (array) $groups as $group_key => $group ) {
			if ( ! is_array( $group ) ) {
				continue;
			}
			$declared_values = array_merge(
				isset( $group['parent_values'] ) ? (array) $group['parent_values'] : array(),
				isset( $group['available_values'] ) ? (array) $group['available_values'] : array()
			);
			if ( '' !== self::positive_constraint_status( implode( ' ', $declared_values ), $constraint ) ) {
				return (string) $group_key;
			}
		}
		return '';
	}

	/**
	 * Values from separate variations must not be combined into a fictional
	 * exact match (for example red on one child and size 42 on another).
	 */
	private static function enforce_available_variation_combination( $candidate, $constraints, &$evidence ) {
		if ( empty( $candidate['_variation_availability']['complete'] ) ) {
			return;
		}
		$mapped = array();
		foreach ( (array) $constraints as $index => $constraint ) {
			$result = isset( $evidence[ $index ] ) ? $evidence[ $index ] : array();
			if ( empty( $constraint['required'] ) || 'include' !== $constraint['polarity'] || empty( $result['_variation_group'] ) ) {
				continue;
			}
			if ( ! in_array( isset( $result['status'] ) ? $result['status'] : '', array( 'literal', 'equivalent', 'descendant' ), true ) ) {
				continue;
			}
			$mapped[ $index ] = array(
				'group'      => (string) $result['_variation_group'],
				'constraint' => $constraint,
			);
		}
		if ( count( $mapped ) < 2 ) {
			return;
		}

		$variations = isset( $candidate['_variation_availability']['variations'] ) ? $candidate['_variation_availability']['variations'] : array();
		foreach ( (array) $variations as $variation ) {
			$matches_all = true;
			foreach ( $mapped as $item ) {
				$values = is_array( $variation ) && isset( $variation[ $item['group'] ] ) ? (array) $variation[ $item['group'] ] : array();
				if ( '' === self::positive_constraint_status( implode( ' ', $values ), $item['constraint'] ) ) {
					$matches_all = false;
					break;
				}
			}
			if ( $matches_all ) {
				return;
			}
		}

		foreach ( array_keys( $mapped ) as $index ) {
			$evidence[ $index ]['status']        = 'missing';
			$evidence[ $index ]['matched_term']  = '';
			$evidence[ $index ]['source_scope']  = 'available_variation_combination';
			$evidence[ $index ]['rejected']      = false;
		}
	}

	/** Exact/inferred-positive status against one bounded attribute value set. */
	private static function positive_constraint_status( $haystack, $constraint ) {
		if ( self::numeric_constraint_matches( $haystack, $constraint ) ) {
			return 'literal';
		}
		if ( null !== $constraint['min'] || null !== $constraint['max'] || null !== $constraint['target'] ) {
			return '';
		}
		if ( '' !== self::first_matching_term( $haystack, array( $constraint['value'] ) ) ) {
			return 'literal';
		}
		if ( '' !== self::first_matching_term( $haystack, $constraint['equivalents'] ) ) {
			return 'equivalent';
		}
		if ( '' !== self::first_matching_term( $haystack, $constraint['descendants'] ) ) {
			return 'descendant';
		}
		return '';
	}

	/**
	 * Bounded prose evidence selected by retrieval for this product/query.
	 * Deliberately excludes title/category/attributes so source strength stays
	 * visible to the classifier.
	 */
	private static function candidate_description_evidence_text( $candidate ) {
		$parts = array(
			isset( $candidate['short_desc'] ) ? $candidate['short_desc'] : '',
			isset( $candidate['content'] ) ? $candidate['content'] : ( isset( $candidate['content_text'] ) ? $candidate['content_text'] : '' ),
			isset( $candidate['evidence']['excerpt'] ) ? $candidate['evidence']['excerpt'] : '',
		);

		return self::normalize_text( implode( ' ', array_filter( array_map( 'strval', $parts ) ) ) );
	}

	private static function candidate_evidence_text( $candidate, $field, $include_discovery = false ) {
		$identity = array(
			isset( $candidate['title'] ) ? $candidate['title'] : '',
			isset( $candidate['categories'] ) ? $candidate['categories'] : ( isset( $candidate['categories_text'] ) ? $candidate['categories_text'] : '' ),
		);
		$attributes = array(
			isset( $candidate['attributes'] ) ? $candidate['attributes'] : ( isset( $candidate['attributes_text'] ) ? $candidate['attributes_text'] : '' ),
		);
		$discovery = array(
			isset( $candidate['tags'] ) ? $candidate['tags'] : ( isset( $candidate['tags_text'] ) ? $candidate['tags_text'] : '' ),
			isset( $candidate['brand'] ) ? $candidate['brand'] : '',
			isset( $candidate['short_desc'] ) ? $candidate['short_desc'] : '',
			isset( $candidate['content'] ) ? $candidate['content'] : ( isset( $candidate['content_text'] ) ? $candidate['content_text'] : '' ),
			isset( $candidate['evidence']['excerpt'] ) ? $candidate['evidence']['excerpt'] : '',
		);

		if ( self::is_core_field( $field ) ) {
			$parts = $identity;
		} elseif ( self::is_attribute_field( $field ) ) {
			$parts = 'weight' === $field ? array_merge( $identity, $attributes ) : $attributes;
		} elseif ( 'brand' === $field ) {
			$parts = array( isset( $candidate['brand'] ) ? $candidate['brand'] : '', isset( $candidate['title'] ) ? $candidate['title'] : '' );
		} elseif ( $include_discovery ) {
			$parts = array_merge( $identity, $attributes, $discovery );
		} else {
			$parts = $attributes;
		}

		return self::normalize_text( implode( ' ', array_filter( array_map( 'strval', $parts ) ) ) );
	}

	private static function is_core_field( $field ) {
		return in_array( $field, array( 'department', 'product_type', 'product_family', 'category', 'gender', 'model', 'sku' ), true );
	}

	private static function is_attribute_field( $field ) {
		return in_array(
			$field,
			array( 'color', 'material', 'size', 'weight', 'capacity', 'dimensions', 'voltage', 'power', 'ingredient', 'form', 'flavor', 'scent', 'skin_type', 'finish', 'mounting', 'chambers' ),
			true
		);
	}

	private static function source_scope_for_field( $field ) {
		if ( self::is_core_field( $field ) ) {
			return 'title_or_category';
		}
		if ( self::is_attribute_field( $field ) ) {
			return 'structured_attribute';
		}
		return 'catalog_evidence';
	}

	private static function first_matching_term( $haystack, $terms ) {
		foreach ( (array) $terms as $term ) {
			if ( self::text_has_term( $haystack, $term ) ) {
				return (string) $term;
			}
		}
		return '';
	}

	private static function text_has_term( $haystack, $term ) {
		$haystack = self::normalize_text( $haystack );
		$term     = self::normalize_text( $term );
		if ( '' === $haystack || '' === $term ) {
			return false;
		}

		$parts = array();
		foreach ( (array) preg_split( '/\s+/u', $term ) as $token ) {
			$length = mb_strlen( $token );
			$root   = $length >= 4 && ! preg_match( '/\p{N}/u', $token ) ? mb_substr( $token, 0, $length - 1 ) : $token;
			if ( mb_strlen( $root ) < 2 ) {
				return false;
			}
			$parts[] = preg_quote( $root, '/' ) . ( $root !== $token ? '\p{L}{0,2}' : '' );
		}

		// A multi-word value is one phrase, not a bag of unrelated tokens that
		// happen to occur somewhere in the same attribute dump.
		if ( 1 === preg_match( '/(?:^|\s)' . implode( '\s+', $parts ) . '(?=$|\s)/u', $haystack ) ) {
			return true;
		}

		/*
		 * One conservative spelling-error pass keeps word order and phrase length
		 * intact. It covers catalog/query variants such as "kozija"/"kozja"
		 * without turning unrelated terms into factual matches.
		 */
		$haystack_tokens = array_values( array_filter( (array) preg_split( '/\s+/u', $haystack ) ) );
		$term_tokens     = array_values( array_filter( (array) preg_split( '/\s+/u', $term ) ) );
		$term_count      = count( $term_tokens );
		if ( 0 === $term_count || count( $haystack_tokens ) < $term_count ) {
			return false;
		}

		$last_start = count( $haystack_tokens ) - $term_count;
		for ( $start = 0; $start <= $last_start; ++$start ) {
			$matches = true;
			foreach ( $term_tokens as $offset => $term_token ) {
				if ( ! self::tokens_are_close( $haystack_tokens[ $start + $offset ], $term_token ) ) {
					$matches = false;
					break;
				}
			}
			if ( $matches ) {
				return true;
			}
		}

		return false;
	}

	/** True for an exact/simple-inflection match or a single typo in a long word. */
	private static function tokens_are_close( $candidate_token, $term_token ) {
		$candidate_token = (string) $candidate_token;
		$term_token      = (string) $term_token;
		if ( $candidate_token === $term_token ) {
			return true;
		}
		if ( preg_match( '/\p{N}/u', $candidate_token . $term_token ) ) {
			return false;
		}

		$term_length      = mb_strlen( $term_token );
		$candidate_length = mb_strlen( $candidate_token );
		if ( $term_length >= 4 ) {
			$root = mb_substr( $term_token, 0, $term_length - 1 );
			if ( 0 === mb_strpos( $candidate_token, $root ) && $candidate_length >= mb_strlen( $root ) && $candidate_length <= mb_strlen( $root ) + 2 ) {
				return true;
			}
		}

		if ( min( $candidate_length, $term_length ) < 5 || abs( $candidate_length - $term_length ) > 1 ) {
			return false;
		}

		$candidate_chars = preg_split( '//u', $candidate_token, -1, PREG_SPLIT_NO_EMPTY );
		$term_chars      = preg_split( '//u', $term_token, -1, PREG_SPLIT_NO_EMPTY );
		$i = 0;
		$j = 0;
		$edits = 0;
		while ( $i < count( $candidate_chars ) && $j < count( $term_chars ) ) {
			if ( $candidate_chars[ $i ] === $term_chars[ $j ] ) {
				++$i;
				++$j;
				continue;
			}
			if ( ++$edits > 1 ) {
				return false;
			}
			if ( $candidate_length > $term_length ) {
				++$i;
			} elseif ( $candidate_length < $term_length ) {
				++$j;
			} else {
				++$i;
				++$j;
			}
		}
		if ( $i < count( $candidate_chars ) || $j < count( $term_chars ) ) {
			++$edits;
		}

		return $edits <= 1;
	}

	/** True only when the catalog explicitly states an absence such as "bez mirisa". */
	private static function text_has_negated_term( $haystack, $term ) {
		$haystack = self::normalize_text( $haystack );
		$term     = self::normalize_text( $term );
		if ( '' === $haystack || '' === $term ) {
			return false;
		}
		$term_parts = array();
		foreach ( (array) preg_split( '/\s+/u', $term ) as $token ) {
			$length = mb_strlen( $token );
			$root   = $length >= 4 && ! preg_match( '/\p{N}/u', $token ) ? mb_substr( $token, 0, $length - 1 ) : $token;
			$term_parts[] = preg_quote( $root, '/' ) . ( $root !== $token ? '\p{L}{0,2}' : '' );
		}
		$term_pattern = implode( '\s+', $term_parts );
		$negators = array( 'bez', 'nije', 'nema', 'ne sadrzi', 'without', 'not', 'no', 'does not contain', 'free from', 'frei von', 'sans', 'sin', 'senza', 'sem' );
		foreach ( $negators as $negator ) {
			if ( preg_match( '/(?:^|\s)' . preg_quote( $negator, '/' ) . '\s+(?:\S+\s+){0,4}' . $term_pattern . '(?:\s|$)/u', $haystack ) ) {
				return true;
			}
		}
		return false;
	}

	private static function numeric_constraint_matches( $haystack, $constraint ) {
		$has_numeric_rule = null !== $constraint['min'] || null !== $constraint['max'] || null !== $constraint['target'];
		if ( ! $has_numeric_rule ) {
			return false;
		}
		$normalized_haystack = self::normalize_text( $haystack );
		$unit                = self::normalize_text( $constraint['unit'] );
		$pattern             = '' !== $unit
			? '/(\d+(?:[.,]\d+)?)\s*' . preg_quote( $unit, '/' ) . '(?=$|\s)/u'
			: '/\d+(?:[.,]\d+)?/u';
		if ( ! preg_match_all( $pattern, $normalized_haystack, $matches ) ) {
			return false;
		}

		$numbers = '' !== $unit && isset( $matches[1] ) ? $matches[1] : $matches[0];
		foreach ( (array) $numbers as $number ) {
			$value = floatval( str_replace( ',', '.', (string) $number ) );
			$min   = null !== $constraint['min'] ? $constraint['min'] : ( null !== $constraint['target'] ? $constraint['target'] - $constraint['tolerance'] : null );
			$max   = null !== $constraint['max'] ? $constraint['max'] : ( null !== $constraint['target'] ? $constraint['target'] + $constraint['tolerance'] : null );
			if ( ( null === $min || $value >= $min ) && ( null === $max || $value <= $max ) ) {
				return true;
			}
		}

		return false;
	}

	private static function numeric_requirement_label( $constraint ) {
		if ( null !== $constraint['target'] ) {
			return (string) $constraint['target'] . ( '' !== $constraint['unit'] ? ' ' . $constraint['unit'] : '' );
		}
		return trim( ( null !== $constraint['min'] ? $constraint['min'] : '' ) . '-' . ( null !== $constraint['max'] ? $constraint['max'] : '' ) . ' ' . $constraint['unit'] );
	}

	private static function compare_candidates( $left, $right ) {
		$class_compare = self::class_priority( isset( $left['match_class'] ) ? $left['match_class'] : '' ) <=> self::class_priority( isset( $right['match_class'] ) ? $right['match_class'] : '' );
		if ( 0 !== $class_compare ) {
			return $class_compare;
		}
		$score_compare = floatval( isset( $right['score'] ) ? $right['score'] : ( isset( $right['_score'] ) ? $right['_score'] : 0 ) ) <=> floatval( isset( $left['score'] ) ? $left['score'] : ( isset( $left['_score'] ) ? $left['_score'] : 0 ) );
		if ( 0 !== $score_compare ) {
			return $score_compare;
		}
		return intval( isset( $left['_retrieval_rank'] ) ? $left['_retrieval_rank'] : 0 ) <=> intval( isset( $right['_retrieval_rank'] ) ? $right['_retrieval_rank'] : 0 );
	}

	private static function best_public_class( $candidates ) {
		foreach ( (array) $candidates as $candidate ) {
			$class = isset( $candidate['match_class'] ) ? (string) $candidate['match_class'] : '';
			if ( self::REJECTED !== $class && '' !== $class ) {
				return $class;
			}
		}
		return '';
	}

	private static function constraint_keys_by_status( $evidence, $statuses ) {
		$keys = array();
		foreach ( (array) $evidence as $item ) {
			if ( is_array( $item ) && in_array( isset( $item['status'] ) ? $item['status'] : '', $statuses, true ) ) {
				$keys[] = isset( $item['key'] ) ? (string) $item['key'] : '';
			}
		}
		return array_values( array_unique( array_filter( $keys ) ) );
	}

	private static function compact_constraint_evidence( $evidence ) {
		$out = array();
		foreach ( (array) $evidence as $item ) {
			if ( ! is_array( $item ) ) {
				continue;
			}
			$out[] = array(
				'key'          => isset( $item['key'] ) ? (string) $item['key'] : '',
				'status'       => isset( $item['status'] ) ? (string) $item['status'] : 'missing',
				'matched_term' => isset( $item['matched_term'] ) ? (string) $item['matched_term'] : '',
				'source_scope' => isset( $item['source_scope'] ) ? (string) $item['source_scope'] : 'catalog_evidence',
			);
		}
		return $out;
	}

	private static function normalize_term_list( $terms ) {
		$out  = array();
		$seen = array();
		foreach ( array_slice( (array) $terms, 0, self::MAX_TERMS ) as $term ) {
			$term = self::clean_text( $term, 120 );
			$key  = self::normalize_text( $term );
			if ( '' !== $key && ! isset( $seen[ $key ] ) ) {
				$seen[ $key ] = true;
				$out[] = $term;
			}
		}
		return $out;
	}

	private static function nullable_number( $value ) {
		return null === $value || '' === $value || ! is_numeric( $value ) ? null : floatval( $value );
	}

	private static function normalize_key( $value ) {
		$value = self::normalize_text( $value );
		$value = preg_replace( '/[^a-z0-9_]+/', '_', str_replace( ' ', '_', $value ) );
		return trim( (string) $value, '_' );
	}

	private static function clean_text( $value, $limit ) {
		$value = trim( wp_strip_all_tags( (string) $value ) );
		return mb_strlen( $value ) > $limit ? mb_substr( $value, 0, $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{N}\s]+/u', ' ', $value );
		return trim( preg_replace( '/\s+/u', ' ', (string) $value ) );
	}
}
