const CAMEL_BOUNDARY = /([a-z0-9])([A-Z])/g;
const CAMEL_NUMBER_BOUNDARY = /([A-Z][a-z]+)(\d)/g;
const NON_WORD = /[^a-z0-9%]+/g;

/** @param {unknown} value */
export function normalize(value) {
  return String(value)
    .replace(CAMEL_BOUNDARY, "$1 $2")
    .replace(CAMEL_NUMBER_BOUNDARY, "$1 $2")
    .toLowerCase()
    .replace(NON_WORD, " ")
    .trim()
    .replace(/\s+/g, " ");
}

/** @param {unknown} value */
function tokens(value) {
  return normalize(value).split(" ").filter((token) => token.length > 1);
}

/** @param {unknown} value */
function trigrams(value) {
  const padded = `  ${normalize(value)} `;
  const values = new Set();
  for (let index = 0; index < padded.length - 2; index += 1) {
    values.add(padded.slice(index, index + 3));
  }
  return values;
}

/** @param {unknown} left @param {unknown} right */
function similarity(left, right) {
  const a = trigrams(left);
  const b = trigrams(right);
  if (!a.size || !b.size) return 0;

  let overlap = 0;
  for (const value of a) if (b.has(value)) overlap += 1;
  return (2 * overlap) / (a.size + b.size);
}

/** @param {unknown} left @param {unknown} right */
export function tokenAffinity(left, right) {
  const a = normalize(left);
  const b = normalize(right);
  if (a === b) return 1;
  if (a.length < 4 || b.length < 4) return 0;
  return similarity(a, b);
}

/** @param {unknown} query @param {unknown} document @param {number} [boost] */
export function relevance(query, document, boost = 0) {
  const needle = normalize(query);
  const haystack = normalize(document);
  if (!needle || !haystack) return 0;

  const words = tokens(needle);
  const haystackWords = new Set(tokens(haystack));
  const exact = ` ${haystack} `.includes(` ${needle} `) ? 30 : 0;
  const coverage = words.length
    ? words.filter((word) => haystackWords.has(word)).length / words.length
    : 0;
  return boost + exact + coverage * 20 + similarity(needle, haystack) * 10;
}
