<?php
/**
 * Search engine — given a query vector, returns top-K product IDs by cosine
 * similarity. For stores < 50k products this is faster than a hybrid FULLTEXT
 * prefilter for accuracy reasons (semantic queries often miss any keyword overlap).
 *
 * @package FindAstra
 */

defined( 'ABSPATH' ) || exit;

final class FindAstra_Search_Engine {

	/**
	 * Match-quality gating.
	 *
	 * Empirically, BGE-small and text-embedding-3-small @ 384 dims pack all
	 * embeddings into a narrow cone — even random gibberish cosines against
	 * real products fall in the 0.50–0.58 range. So an absolute floor alone
	 * isn't enough to recognize a bad query.
	 *
	 * Strategy:
	 *   MIN_TOP_SCORE — the BEST hit must reach this for ANY results to count.
	 *                   If the top score is below it, return empty (fallback
	 *                   products fire downstream).
	 *   MIN_SCORE     — any individual hit must reach this absolute floor.
	 *   SCORE_WINDOW  — relative drop allowed from the top hit. Lets us cut
	 *                   off the long tail of weakly related products.
	 *
	 * Measured on the seed catalog (HuggingFace / bge-small):
	 *   - "comfortable hiking shoes" → top 0.68, relevant tail to 0.61
	 *   - random gibberish           → top 0.58, tail to 0.51
	 *
	 * 0.60 / 0.55 / 0.12 cleanly separates the two without losing real hits.
	 * All three are filterable.
	 */
	public const DEFAULT_MIN_TOP_SCORE = 0.60;
	public const DEFAULT_MIN_SCORE     = 0.55;
	public const DEFAULT_SCORE_WINDOW  = 0.12;

	/**
	 * Adaptive "standout" sensitivity, in robust standard deviations.
	 *
	 * A genuine match stands out from the background of unrelated products;
	 * gibberish does not. We measure that separation with robust statistics
	 * (median + MAD) and call the top hit a standout when it sits at least
	 * STANDOUT_K robust-σ above the median. Because the unit is σ — measured
	 * from the catalog's own score spread — this number is the SAME for every
	 * model and every catalog. It's what lets search self-calibrate instead of
	 * relying on a per-model absolute threshold. Overridable via the
	 * `findastra_standout_k` filter. ~3σ ≈ a clear, unambiguous outlier.
	 */
	public const DEFAULT_STANDOUT_K = 3.0;

	/**
	 * Above this many indexed products, search() automatically uses the
	 * hybrid FULLTEXT prefilter path (if both a query text and the index
	 * are available). Below it, plain cosine over the whole vector set is
	 * fast enough and avoids cutting recall on terse semantic queries that
	 * don't lexically match anything.
	 */
	public const DEFAULT_HYBRID_THRESHOLD = 5000;

	/**
	 * Hybrid path takes the top N candidates from FULLTEXT and re-ranks
	 * with cosine. N is wide enough that the cosine still has room to
	 * pull semantically-relevant items out of a noisy lexical recall.
	 */
	public const DEFAULT_HYBRID_PREFILTER_LIMIT = 200;

	public function __construct(
		private FindAstra_Vector_Store $store,
		private FindAstra_Provider_Base $provider
	) {}

	/**
	 * @param float[]     $query_vector
	 * @param string      $query_text   Optional. If non-empty and the catalog
	 *                                  is above DEFAULT_HYBRID_THRESHOLD and
	 *                                  the wp_posts FULLTEXT index is
	 *                                  installed, the search uses the hybrid
	 *                                  path: FULLTEXT prefilter → cosine
	 *                                  re-rank.
	 * @param string|null $language     Optional locale to restrict the
	 *                                  candidate set to (e.g. 'fr' on a
	 *                                  WPML/Polylang French archive). null
	 *                                  means "no restriction" — search across
	 *                                  every indexed language.
	 * @return array<int, array{product_id:int, score:float}>
	 */
	public function search( array $query_vector, int $limit = 12, string $query_text = '', ?string $language = null ): array {
		if ( '' !== trim( $query_text ) && $this->should_use_hybrid() ) {
			$ids = $this->fulltext_candidate_ids( $query_text );
			if ( ! empty( $ids ) ) {
				$subset = $this->store->load_for_ids( $ids, $this->provider->key(), $this->provider->model(), $language );
				if ( ! empty( $subset ) ) {
					return $this->rank_and_gate( $query_vector, $subset, $limit );
				}
			}
			// Empty FULLTEXT recall → fall through to full cosine, otherwise
			// terse semantic queries that don't lexically match anything
			// would silently return nothing.
		}

		$all = $this->store->load_all( $this->provider->key(), $this->provider->model(), $language );
		if ( empty( $all ) ) {
			return array();
		}
		return $this->rank_and_gate( $query_vector, $all, $limit );
	}

	/**
	 * @param float[] $query_vector
	 * @param array<int, float[]> $candidates  product_id => vector
	 * @return array<int, array{product_id:int, score:float}>
	 */
	private function rank_and_gate( array $query_vector, array $candidates, int $limit ): array {

		// Per-model anchors come from the active provider. They're still useful
		// as a fast "definitely a match" bar and as the selection floor/window,
		// and the `findastra_min_*` filters (Search → Match-quality gates) let
		// an admin override them. But they're no longer the SOLE gate — see the
		// adaptive standout test below.
		$base    = $this->provider->default_gates();
		$anchor  = (float) apply_filters( 'findastra_min_top_score', $base['top']    ?? self::DEFAULT_MIN_TOP_SCORE );
		$min_any = (float) apply_filters( 'findastra_min_score',     $base['min']    ?? self::DEFAULT_MIN_SCORE );
		$window  = (float) apply_filters( 'findastra_score_window',  $base['window'] ?? self::DEFAULT_SCORE_WINDOW );
		$k       = (float) apply_filters( 'findastra_standout_k',    self::DEFAULT_STANDOUT_K );

		$scored = array();
		foreach ( $candidates as $product_id => $vec ) {
			$scored[] = array(
				'product_id' => $product_id,
				'score'      => FindAstra_Vector_Store::dot( $query_vector, $vec ),
			);
		}
		if ( empty( $scored ) ) {
			return array();
		}

		usort(
			$scored,
			static fn( $a, $b ) => $b['score'] <=> $a['score']
		);

		$values = array_map( static fn( $r ) => $r['score'], $scored );
		$top    = $values[0];

		// Adaptive "standout" test. A real match sits clearly above the
		// background of unrelated products; gibberish sits in the pack. We
		// measure that with robust statistics so it auto-calibrates to whatever
		// score range the active model/catalog produces — no per-model number.
		//   median  → the background ("typical unrelated") score for this query
		//   spread  → robust σ (1.4826·MAD), resistant to the few high matches
		//   bar     → median + k·spread, the outlier line
		$median = self::median( $values );
		$spread = 1.4826 * self::median(
			array_map( static fn( $s ) => abs( $s - $median ), $values )
		);
		$bar         = $median + $k * $spread;
		$is_standout = $spread > 1e-9 && $top >= $bar && $top >= $min_any;

		// Gate: pass when the top hit is absolutely strong (per-model anchor)
		// OR a clear statistical standout. Keeping BOTH is the point — it makes
		// search resilient to a mis-tuned anchor. If the shipped/admin number
		// is wrong for this buyer's catalog (e.g. a digital-goods store whose
		// real matches top out lower than the default), the standout test still
		// surfaces genuine results instead of dead-ending on the popular-
		// products fallback. Conversely, when nothing stands out and nothing is
		// absolutely strong, we correctly fall back.
		if ( $top < $anchor && ! $is_standout ) {
			return array();
		}

		// Selection: keep hits above the per-model floor AND within $window of
		// the top hit — drops the long tail of weakly related products.
		$cutoff   = max( $min_any, $top - $window );
		$filtered = array_values( array_filter( $scored, static fn( $r ) => $r['score'] >= $cutoff ) );

		return array_slice( $filtered, 0, $limit );
	}

	/**
	 * Median of a numeric list (average of the two middle values for an even
	 * count). Returns 0.0 for an empty list. Used by the adaptive standout
	 * gate — median + MAD are robust against the handful of high-scoring real
	 * matches that would skew a mean/stddev.
	 *
	 * @param float[] $values
	 */
	private static function median( array $values ): float {
		$values = array_values( $values );
		$n      = count( $values );
		if ( 0 === $n ) {
			return 0.0;
		}
		sort( $values, SORT_NUMERIC );
		$mid = intdiv( $n, 2 );
		if ( 1 === $n % 2 ) {
			return (float) $values[ $mid ];
		}
		return ( (float) $values[ $mid - 1 ] + (float) $values[ $mid ] ) / 2.0;
	}

	/**
	 * Decide whether the current query should use the hybrid FULLTEXT path.
	 * True iff the catalog is above the threshold AND the index is installed
	 * AND no filter has disabled hybrid mode.
	 */
	public function should_use_hybrid(): bool {
		$status = (string) get_option( FindAstra_Activator::FULLTEXT_STATUS_OPTION, '' );
		if ( 'installed' !== $status ) {
			return false;
		}
		$threshold = (int) apply_filters( 'findastra_hybrid_threshold', self::DEFAULT_HYBRID_THRESHOLD );
		if ( $threshold <= 0 ) {
			return false;
		}
		$count = $this->store->count_for( $this->provider->key(), $this->provider->model() );
		return $count >= $threshold;
	}

	/**
	 * FULLTEXT prefilter — return up to N product IDs whose title/content
	 * best lexically matches the query. NATURAL LANGUAGE MODE with query
	 * expansion ramps recall (good — cosine re-rank cuts the noise back
	 * down), and MATCH() AGAINST returns a relevance score so the LIMIT
	 * cuts the long tail before we move any data over the wire.
	 *
	 * @return int[]
	 */
	public function fulltext_candidate_ids( string $query_text ): array {
		global $wpdb;
		$limit = (int) apply_filters( 'findastra_hybrid_prefilter_limit', self::DEFAULT_HYBRID_PREFILTER_LIMIT );
		if ( $limit <= 0 ) {
			return array();
		}
		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.DirectQuery
		$rows = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT ID FROM {$wpdb->posts}
				 WHERE post_type = 'product' AND post_status = 'publish'
				   AND MATCH(post_title, post_content) AGAINST (%s IN NATURAL LANGUAGE MODE WITH QUERY EXPANSION)
				 LIMIT %d",
				$query_text,
				$limit
			)
		);
		if ( ! is_array( $rows ) ) {
			return array();
		}
		return array_map( 'intval', $rows );
	}

	/**
	 * @param array<int, array{product_id:int, score:float}> $hits
	 * @return array<int, array{product_id:int, score:float, title:string, permalink:string, price_html:string, image:string}>
	 */
	public function hydrate( array $hits ): array {
		$out = array();
		foreach ( $hits as $hit ) {
			$product = wc_get_product( $hit['product_id'] );
			if ( ! $product instanceof WC_Product ) {
				continue;
			}
			$thumb = wp_get_attachment_image_url( $product->get_image_id(), 'thumbnail' );
			$out[] = array(
				'product_id' => (int) $hit['product_id'],
				'score'      => (float) $hit['score'],
				'title'      => wp_strip_all_tags( $product->get_name() ),
				'permalink'  => get_permalink( $product->get_id() ),
				'price_html' => wp_kses_post( $product->get_price_html() ),
				'image'      => $thumb ? esc_url_raw( $thumb ) : '',
			);
		}
		return $out;
	}
}
