<?php
/**
 * Truebe matcher: deterministic keyword/token scoring (ported from the proven
 * Foodbe engine). Takes a visitor question and returns the best matching READY
 * seed, a seed's followup, an ambiguous near-tie, or a miss with suggestions.
 *
 * No embeddings, no query-time API call. Fully explainable: a match is justified
 * by the specific words that overlapped. Storage lives in Truebe_Seeds; this
 * class only reads seeds and scores them.
 *
 * This version enforces the seed authoring contract at runtime:
 *   - Distinctive-token gate: a seed can only win on a token that is distinctive
 *     to it (a rare token used by very few seeds). Shared words like "physician"
 *     cannot carry a match; "attending" or "resident" can. This stops bleed.
 *   - Near-tie margin: if the top two seeds score within the margin, the matcher
 *     refuses to guess and returns them both as suggestions (ambiguous).
 *   - Suggestions on a miss: when nothing clears the bar, the nearest seeds are
 *     returned so the caller can offer "did you mean" instead of an empty gap.
 */

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

if ( class_exists( 'Truebe_Matcher' ) ) {
	return;
}

class Truebe_Matcher {

	const SCORE_THRESHOLD_SEED     = 40; // Minimum score for a seed match.
	const SCORE_THRESHOLD_FOLLOWUP = 60; // Higher bar for a followup match.
	const SCORE_MARGIN             = 12; // Top two within this = a near-tie (ambiguous).
	const DISTINCTIVE_MAX_SEEDS    = 2;  // A rare token in this many seeds or fewer is distinctive.
	const MAX_SUGGESTIONS          = 3;  // How many "did you mean" options to offer.

	/** Generic words carry no matching signal — stripped before scoring. */
	private static $generic_words = array(
		'how','what','is','the','to','a','an','my','your','should','can','do','does',
		'in','of','or','at','by','on','so','if','as','be','we','was','are','its','has',
		'had','not','but','for','and','that','this','with','from','just','about','which',
		'who','will','would','could','their','there','they','them','then','than','into',
		'also','even','only','very','too','get','got','use','used','have','been','being',
		'me','i','us','you','it','our','all','some','any','more','most','good','bad','best',
		'really','always','never','new','old','day','time','way','help','need','want','like',
		'per','each','every','am','were','where','when','why','because','while','after','before',
		'no','now','today','still','thing','things','type','types','kind','kinds',
	);

	private static function tokenize( $text ) {
		$clean = trim( preg_replace( '/[^a-z0-9]+/', ' ', strtolower( (string) $text ) ) );
		if ( '' === $clean ) {
			return array();
		}
		return preg_split( '/\s+/', $clean, -1, PREG_SPLIT_NO_EMPTY );
	}

	private static function stem( $token ) {
		$len = strlen( $token );
		if ( $len < 4 ) {
			return $token;
		}
		if ( $len > 4 && substr( $token, -3 ) === 'ies' ) {
			return substr( $token, 0, -3 ) . 'y';
		}
		if ( $len > 5 && substr( $token, -3 ) === 'ing' ) {
			return substr( $token, 0, -3 );
		}
		if ( $len > 4 && substr( $token, -2 ) === 'ed' ) {
			return substr( $token, 0, -2 );
		}
		if ( $len > 4 && substr( $token, -2 ) === 'es' ) {
			return substr( $token, 0, -2 );
		}
		if ( $len > 4 && substr( $token, -1 ) === 's' ) {
			return substr( $token, 0, -1 );
		}
		return $token;
	}

	private static function is_meaningful( $token ) {
		return ( strlen( $token ) >= 2 && ! in_array( $token, self::$generic_words, true ) );
	}

	private static function is_rare( $token ) {
		return ( self::is_meaningful( $token ) && strlen( $token ) >= 4 );
	}

	private static function stem_set( $tokens ) {
		$set = array();
		foreach ( $tokens as $t ) {
			$set[ $t ]               = true;
			$set[ self::stem( $t ) ] = true;
		}
		return $set;
	}

	private static function in_stem_set( $token, $set ) {
		return isset( $set[ $token ] ) || isset( $set[ self::stem( $token ) ] );
	}

	/**
	 * Collect a seed's rare token stems from its label, phrasings, and keywords.
	 *
	 * @param array $seed
	 * @return array Set of rare stems for this seed.
	 */
	private static function seed_rare_stems( $seed ) {
		$texts = array( isset( $seed['label'] ) ? $seed['label'] : '' );
		if ( ! empty( $seed['phrasings'] ) && is_array( $seed['phrasings'] ) ) {
			$texts = array_merge( $texts, $seed['phrasings'] );
		}
		if ( ! empty( $seed['keywords'] ) && is_array( $seed['keywords'] ) ) {
			$texts = array_merge( $texts, $seed['keywords'] );
		}
		$stems = array();
		foreach ( $texts as $text ) {
			foreach ( self::tokenize( $text ) as $tok ) {
				if ( self::is_rare( $tok ) ) {
					$stems[ self::stem( $tok ) ] = true;
				}
			}
		}
		return $stems;
	}

	/**
	 * Build the distinctive-token index across all ready seeds.
	 * Returns, per seed index, the set of stems that are distinctive (used by few seeds).
	 *
	 * @param array[] $seeds
	 * @return array[] Map of seed index => set of distinctive stems.
	 */
	private static function build_distinctive_index( $seeds ) {
		$seed_stems = array();
		$doc_freq   = array();

		foreach ( $seeds as $i => $seed ) {
			$stems            = self::seed_rare_stems( $seed );
			$seed_stems[ $i ] = $stems;
			foreach ( array_keys( $stems ) as $stem ) {
				$doc_freq[ $stem ] = isset( $doc_freq[ $stem ] ) ? $doc_freq[ $stem ] + 1 : 1;
			}
		}

		$distinctive = array();
		foreach ( $seed_stems as $i => $stems ) {
			$set = array();
			foreach ( array_keys( $stems ) as $stem ) {
				if ( $doc_freq[ $stem ] <= self::DISTINCTIVE_MAX_SEEDS ) {
					$set[ $stem ] = true;
				}
			}
			$distinctive[ $i ] = $set;
		}
		return $distinctive;
	}

	private static function score_phrase( $user_clean, $user_set, $phrase ) {
		$matched = array();
		$rare    = false;
		$score   = 0;

		$ptokens = array_filter( self::tokenize( $phrase ), array( __CLASS__, 'is_meaningful' ) );
		if ( empty( $ptokens ) ) {
			return array( 'score' => 0, 'matched' => $matched, 'rare' => false );
		}
		$pclean = implode( ' ', $ptokens );

		if ( '' !== $pclean && false !== strpos( $user_clean, $pclean ) ) {
			$score += 15;
			foreach ( $ptokens as $w ) {
				$matched[ $w ] = true;
				if ( self::is_rare( $w ) ) {
					$rare = true;
				}
			}
		} else {
			$hits = 0;
			foreach ( $ptokens as $w ) {
				if ( self::in_stem_set( $w, $user_set ) ) {
					$hits++;
					$matched[ $w ] = true;
					if ( self::is_rare( $w ) ) {
						$rare = true;
					}
				}
			}
			if ( $hits > 0 ) {
				$score += (int) floor( ( $hits / count( $ptokens ) ) * 10 );
			}
		}

		return array( 'score' => $score, 'matched' => $matched, 'rare' => $rare );
	}

	/**
	 * Match a visitor question.
	 *
	 * @param string $question
	 * @return array|null {
	 *     status:      'match' | 'followup' | 'ambiguous' | 'miss'
	 *     seed:        array|null (the matched seed; null on ambiguous/miss)
	 *     score:       int
	 *     is_followup: bool
	 *     followup:    array|null
	 *     suggestions: array[] of { id, label } (on ambiguous/miss)
	 * }
	 * Returns null only on empty input or empty library.
	 */
public static function match( $question, $doc_filter = '' ) {
		$question = (string) $question;
		if ( '' === trim( $question ) ) {
			return null;
		}

		$utokens     = self::tokenize( $question );
		$user_clean  = implode( ' ', $utokens );
		$meaningful  = array_values( array_filter( $utokens, array( __CLASS__, 'is_meaningful' ) ) );
		$rare_tokens = array_values( array_filter( $utokens, array( __CLASS__, 'is_rare' ) ) );
		$user_set    = self::stem_set( $meaningful );

		$seeds = Truebe_Seeds::get_ready_seeds();
		if ( empty( $seeds ) ) {
			return null;
		}

		// Internal research chat may limit answers to a single document.
		$doc_filter = (string) $doc_filter;
		if ( '' !== $doc_filter ) {
			$seeds = array_filter( $seeds, function ( $s ) use ( $doc_filter ) {
				return isset( $s['source_doc'] ) && (string) $s['source_doc'] === $doc_filter;
			} );
			if ( empty( $seeds ) ) {
				return null;
			}
		}

		$seeds       = array_values( $seeds );
		$distinctive = self::build_distinctive_index( $seeds );

		$ranked = array(); // Qualified candidates: { seed, score }.
		$loose  = array(); // Any seed with signal, for suggestions: { seed, score }.

		foreach ( $seeds as $i => $seed ) {
			// Followup exact match short-circuits (intentional deep match).
			foreach ( (array) $seed['followups'] as $fu ) {
				if ( empty( $fu['question'] ) ) {
					continue;
				}
				if ( implode( ' ', self::tokenize( $fu['question'] ) ) === $user_clean && '' !== $user_clean ) {
					return self::result_match( $seed, 1000, true, $fu );
				}
			}

			// Exact label match short-circuits to a strong score.
			$label_clean = implode( ' ', self::tokenize( $seed['label'] ) );
			$had_exact   = false;
			if ( $label_clean === $user_clean && '' !== $user_clean ) {
				return self::result_match( $seed, 100, false, null );
			}

			$score   = 0;
			$matched = array();
			$rare    = false;

			$phrases = array_merge(
				is_array( $seed['phrasings'] ) ? $seed['phrasings'] : array(),
				is_array( $seed['keywords'] ) ? $seed['keywords'] : array()
			);
			foreach ( $phrases as $phrase ) {
				if ( '' === (string) $phrase ) {
					continue;
				}
				if ( false !== strpos( $user_clean, strtolower( trim( (string) $phrase ) ) ) ) {
					$score    += 50;
					$had_exact = true;
				}
				$r       = self::score_phrase( $user_clean, $user_set, (string) $phrase );
				$score  += $r['score'];
				$matched = $matched + $r['matched'];
				$rare    = $rare || $r['rare'];
			}

			$anchor_score = 0;
			foreach ( (array) $seed['anchors'] as $anchor ) {
				foreach ( array_filter( self::tokenize( $anchor ), array( __CLASS__, 'is_meaningful' ) ) as $t ) {
					if ( self::in_stem_set( $t, $user_set ) ) {
						$anchor_score += 1;
						$matched[ $t ] = true;
						if ( self::is_rare( $t ) ) {
							$rare = true;
						}
					}
				}
			}
			$score += min( $anchor_score, 8 );

			if ( 'high' === $seed['priority'] ) {
				$score = (int) floor( $score * 1.3 );
			}

			// Only offer a seed as a "did you mean" suggestion if it has real
			// signal, not a single stray anchor-word overlap — otherwise unrelated
			// seeds surface on genuine misses like "tell me about tuna".
			if ( $score >= 5 ) {
				$loose[] = array( 'seed' => $seed, 'score' => $score );
			}

			// Semantic guard: need real signal and a rare-token overlap.
			if ( count( $matched ) < 2 && ! ( $rare && $score >= 7 ) ) {
				continue;
			}
			$rare_overlap = false;
			foreach ( $rare_tokens as $rt ) {
				if ( isset( $matched[ $rt ] ) ) {
					$rare_overlap = true;
					break;
				}
			}
			if ( ! $rare_overlap ) {
				continue;
			}

			// Distinctive-token gate: the question must hit a token distinctive to THIS seed,
			// unless an exact phrase/label substring already pinned it.
			if ( ! $had_exact ) {
				$has_distinctive = false;
				foreach ( array_keys( $distinctive[ $i ] ) as $dstem ) {
					if ( isset( $user_set[ $dstem ] ) ) {
						$has_distinctive = true;
						break;
					}
				}
				if ( ! $has_distinctive ) {
					continue;
				}
			}

			$ranked[] = array( 'seed' => $seed, 'score' => $score );
		}

		// Followup scored matches (higher bar), checked against the loose-best later.
		$best_fu       = null;
		$best_fu_seed  = null;
		$best_fu_score = 0;
		foreach ( $seeds as $seed ) {
			foreach ( (array) $seed['followups'] as $fu ) {
				if ( empty( $fu['question'] ) ) {
					continue;
				}
				$fu_tokens = array_filter( self::tokenize( $fu['question'] ), array( __CLASS__, 'is_meaningful' ) );
				if ( empty( $fu_tokens ) ) {
					continue;
				}
				$fu_hits = 0;
				foreach ( $fu_tokens as $t ) {
					if ( self::in_stem_set( $t, $user_set ) ) {
						$fu_hits++;
					}
				}
				$fu_score = (int) floor( ( $fu_hits / count( $fu_tokens ) ) * 50 );
				if ( $fu_score >= self::SCORE_THRESHOLD_FOLLOWUP && $fu_score > $best_fu_score ) {
					$best_fu_score = $fu_score;
					$best_fu       = $fu;
					$best_fu_seed  = $seed;
				}
			}
		}

		// Rank qualified candidates.
		usort( $ranked, array( __CLASS__, 'by_score_desc' ) );

		$top        = isset( $ranked[0] ) ? $ranked[0] : null;
		$top_score  = $top ? (int) $top['score'] : 0;

		// A strong followup beats a weak seed match.
		if ( null !== $best_fu && $best_fu_score > $top_score ) {
			return self::result_match( $best_fu_seed, $best_fu_score, true, $best_fu );
		}

		// No qualified seed clears the bar: a miss, with suggestions.
		if ( null === $top || $top_score < self::SCORE_THRESHOLD_SEED ) {
			return self::result_miss( self::suggestions_from( $loose ) );
		}

		// Near-tie: top two too close to call. Try intent as a tie-breaker first;
		// if intent cannot decide, refuse to guess and offer both.
		if ( isset( $ranked[1] ) ) {
			$second = (int) $ranked[1]['score'];
			if ( $second >= self::SCORE_THRESHOLD_SEED && ( $top_score - $second ) < self::SCORE_MARGIN ) {
				if ( class_exists( 'Truebe_Intent' ) ) {
					$winner = Truebe_Intent::break_tie(
						$question,
						array( $ranked[0]['seed'], $ranked[1]['seed'] )
					);
					if ( null !== $winner ) {
						return self::result_match( $winner, $top_score, false, null );
					}
				}
				$pair = array(
					array( 'seed' => $ranked[0]['seed'], 'score' => $top_score ),
					array( 'seed' => $ranked[1]['seed'], 'score' => $second ),
				);
				return self::result_ambiguous( self::suggestions_from( $pair ) );
			}
		}

		// Confident single winner.
		return self::result_match( $top['seed'], $top_score, false, null );
	}

	private static function by_score_desc( $a, $b ) {
		return (int) $b['score'] - (int) $a['score'];
	}

	/**
	 * Build a de-duplicated suggestion list (id + label) from scored candidates.
	 *
	 * @param array[] $scored List of { seed, score }.
	 * @return array[] List of { id, label }.
	 */
	private static function suggestions_from( $scored ) {
		usort( $scored, array( __CLASS__, 'by_score_desc' ) );
		$out  = array();
		$seen = array();
		foreach ( $scored as $row ) {
			$seed  = $row['seed'];
			$label = isset( $seed['label'] ) ? (string) $seed['label'] : '';
			if ( '' === $label || isset( $seen[ $label ] ) ) {
				continue;
			}
			$seen[ $label ] = true;
			$out[]          = array(
				'id'    => isset( $seed['id'] ) ? (int) $seed['id'] : 0,
				'label' => $label,
			);
			if ( count( $out ) >= self::MAX_SUGGESTIONS ) {
				break;
			}
		}
		return $out;
	}

	private static function result_match( $seed, $score, $is_followup, $followup ) {
		return array(
			'status'      => $is_followup ? 'followup' : 'match',
			'seed'        => $seed,
			'score'       => (int) $score,
			'is_followup' => (bool) $is_followup,
			'followup'    => $followup,
			'suggestions' => array(),
		);
	}

	private static function result_ambiguous( $suggestions ) {
		return array(
			'status'      => 'ambiguous',
			'seed'        => null,
			'score'       => 0,
			'is_followup' => false,
			'followup'    => null,
			'suggestions' => $suggestions,
		);
	}

	private static function result_miss( $suggestions ) {
		return array(
			'status'      => 'miss',
			'seed'        => null,
			'score'       => 0,
			'is_followup' => false,
			'followup'    => null,
			'suggestions' => $suggestions,
		);
	}
}