/**
 * Text normalisation shared by the embedding pipeline and the keyword ranker.
 *
 * Both stages must agree on what a "term" is: if the indexer stores `headphones`
 * but the query ranker looks for `headphones`, an `includes()` comparison is all
 * that stands between a match and a miss.
 */

const STOP_WORDS = new Set([
  "a", "about", "all", "also", "am", "an", "and", "any", "are", "as", "at", "be",
  "been", "being", "but", "by", "can", "could", "did", "do", "does", "doing",
  "for", "from", "further", "had", "has", "have", "having", "he", "her", "here",
  "hers", "him", "his", "how", "i", "if", "in", "into", "is", "it", "its", "just",
  "like", "me", "more", "most", "my", "no", "nor", "not", "now", "of", "off",
  "on", "once", "only", "or", "other", "ought", "our", "ours", "out", "over",
  "own", "please", "same", "she", "should", "so", "some", "such", "than", "that",
  "the", "their", "theirs", "them", "then", "there", "these", "they", "this",
  "those", "through", "to", "too", "under", "until", "up", "us", "very", "was",
  "we", "were", "what", "when", "where", "which", "while", "who", "whom", "why",
  "will", "with", "would", "you", "your", "yours",
]);

/**
 * Irregular plural / past-tense forms that a suffix stripper cannot handle.
 * Keeping this small and explicit beats pulling in a stemming dependency for a
 * 40-document corpus.
 */
const IRREGULAR_FORMS: Record<string, string> = {
  shoes: "shoe",
  glasses: "glass",
  boxes: "box",
  watches: "watch",
  headphones: "headphone",
  earbuds: "earbud",
  trousers: "trouser",
  jeans: "jean",
  socks: "sock",
  running: "run",
  training: "train",
  travellers: "travel",
  traveller: "travel",
  travelling: "travel",
  traveling: "travel",
  buying: "buy",
  gifts: "gift",
  presents: "gift",
  knives: "knife",
  leaves: "leaf",
  lives: "life",
  batteries: "battery",
  lovers: "love",
  loves: "love",
};

/**
 * Collapse English inflections to a stable stem. Deliberately conservative: it
 * strips the endings that create obvious duplicate tokens and nothing more, so
 * "mat" is never stemmed into "m".
 */
export function stem(token: string): string {
  const irregular = IRREGULAR_FORMS[token];
  if (irregular) return irregular;

  if (token.length > 4 && token.endsWith("ies")) return `${token.slice(0, -3)}y`;
  if (token.length > 4 && token.endsWith("sses")) return token.slice(0, -2);
  if (token.length > 3 && token.endsWith("s") && !token.endsWith("ss") && !token.endsWith("us")) {
    return token.slice(0, -1);
  }
  if (token.length > 5 && token.endsWith("ing")) {
    const base = token.slice(0, -3);
    return base.length > 2 && base.at(-1) === base.at(-2) ? base.slice(0, -1) : base;
  }
  if (token.length > 4 && token.endsWith("ed")) return token.slice(0, -2);
  if (token.length > 4 && token.endsWith("ly")) return token.slice(0, -2);

  return token;
}

export function isStopWord(token: string): boolean {
  return STOP_WORDS.has(token);
}

export interface TokenizeOptions {
  /** Drop English function words. Query-side default; kept for index-side documents. */
  removeStopWords?: boolean;
  /** Apply the inflection stemmer. */
  stemTokens?: boolean;
}

/** Split arbitrary text into comparable normalised terms. */
export function tokenize(text: string, options: TokenizeOptions = {}): string[] {
  const { removeStopWords = true, stemTokens = true } = options;

  const raw = text
    .toLowerCase()
    .replace(/[^a-z0-9\s.+-]/g, " ")
    .replace(/[-_/]+/g, " ")
    .split(/\s+/)
    .filter(Boolean);

  const tokens: string[] = [];
  for (const token of raw) {
    if (token.length < 2 && !/\d/.test(token)) continue;
    if (removeStopWords && isStopWord(token)) continue;
    tokens.push(stemTokens ? stem(token) : token);
  }

  return tokens;
}

/** Collapse whitespace and trim. Used for the text written into the vector file. */
export function normalizeWhitespace(text: string): string {
  return text.replace(/\s+/g, " ").trim();
}

/**
 * Build the single string that represents a product in the vector space.
 *
 * Field repetition is deliberate: the brand and category tokens are repeated so
 * a query that names a brand is pulled towards that brand's products, and the
 * tags are appended because they are the most keyword-dense part of the record.
 */
export function buildProductSearchText(product: {
  name: string;
  description: string;
  category: string;
  subcategory: string;
  brand: string;
  tags: string[];
}): string {
  return normalizeWhitespace(
    [
      product.name,
      product.name,
      product.subcategory,
      product.category,
      product.brand,
      product.brand,
      product.description,
      product.tags.join(" "),
    ].join(" "),
  );
}

const FNV_OFFSET = 0x811c9dc5;

/** Stable 32-bit FNV-1a hash, used for cache keys and source fingerprints. */
export function fnv1a(input: string): string {
  let hash = FNV_OFFSET;
  for (let i = 0; i < input.length; i += 1) {
    hash ^= input.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return (hash >>> 0).toString(16).padStart(8, "0");
}