/**
 * Suggests which existing customer a printed insured name belongs to.
 *
 * The office books customers surname-first ("WAGONER, PAMELA") and carriers
 * print them given-name-first ("PAMELA DENISE WAGONER"), so a string compare
 * never hits. Comparing *token sets* does, and it is order-insensitive by
 * construction — which is the whole trick.
 *
 * **These are suggestions, never matches.** Nothing here sets
 * `matchedCustomerId` or `confident`; the review screen offers the ranked
 * names and a human picks. That line is not caution, it is what the book
 * measures out to: of 1536 customers, 1487 have a distinct normalized token
 * set — but loosen the rule to surname + first given name only and 131 of
 * them (8.5%) collide, because the book holds `MCWILLIAMS, BRIAN MICHAEL`
 * *and* `MCWILLIAMS, BRIAN`, and `CUADROS, JORGE JR` alongside three
 * `CUADROS, JORGE H.`. 185 surnames are shared by 524 customers, so a
 * surname alone carries no information at all.
 *
 * The two tiers below are drawn at the two places that measurement puts a
 * cliff: full token-set equality, where cross-person collisions are
 * effectively zero, and strict containment, where they are common enough
 * that the result can only ever be a hint.
 */

/** A customer row as the matcher needs it — id and the book's name. */
export interface CustomerNameRow {
  id: string;
  name: string;
}

export type NameMatchTier = "EXACT" | "PARTIAL";

export interface CustomerNameSuggestion {
  customerId: string;
  customerName: string;
  /**
   * `EXACT` — the two names carry the same tokens, in any order.
   * `PARTIAL` — one name's tokens are all present in the other's, plus the
   * surname. A printed middle name the book does not hold, or a joint
   * account where the carrier named one spouse, both land here.
   */
  tier: NameMatchTier;
  /** Shared tokens over the longer name's token count, 0..1. */
  score: number;
}

/**
 * Words that carry no identity. Spanish particles and the ampersand joining
 * a couple are noise; the corporate suffixes are dropped so `S.A. DE C.V.`
 * does not make every company look alike.
 */
const NOISE = new Set([
  "DE", "DEL", "LA", "LAS", "LOS", "Y", "AND", "VDA",
  "JR", "SR", "II", "III", "IV",
  "SA", "CV", "SAPI", "SRL", "RL", "SC", "INC", "LLC", "LTD", "CORP", "CO",
]);

/**
 * Placeholder rows the migration left behind. Fourteen customers are named
 * literally `(SIN NOMBRE)`; without this they would be one 14-way tie on
 * every unreadable name.
 */
const PLACEHOLDER = new Set(["SIN NOMBRE", "NOMBRE SIN"]);

/**
 * A name blob longer than this is not a name. GMX's PVL especificación has
 * no field labels, and the parser has been seen handing its entire first
 * page over as `insuredName`; matching that against the book would find
 * a surname somewhere in the prose and suggest a stranger.
 */
const MAX_TOKENS = 8;
const MAX_CHARS = 80;

/**
 * Splits a name into comparable tokens.
 *
 * Accents go first, and deliberately in both directions: the book holds
 * `MUÑOZ` where OCR routinely reads `MUNOZ`, and folding both to the same
 * ASCII makes that a hit rather than a miss.
 *
 * Tokens containing digits are dropped outright. ANA's automobile face
 * prints the phone number hard against the insured name — the parser has
 * emitted `MARIA GARCIA Ph.3102001538` — and the digits would otherwise
 * be an extra token forever blocking `EXACT`.
 *
 * Single letters are dropped as initials: the book is full of
 * `WEAKLAND, RICHARD E.`, and a carrier that prints the middle name in
 * full should still match the row that abbreviates it.
 */
export function nameTokens(raw: string): string[] {
  const cleaned = raw
    .normalize("NFD")
    .replace(/[\u0300-\u036f]/g, "")
    .toUpperCase()
    .replace(/[^A-Z0-9]+/g, " ")
    .trim();

  const tokens = cleaned
    .split(" ")
    .filter((t) => t.length > 1 && !/\d/.test(t) && !NOISE.has(t));

  return [...new Set(tokens)];
}

/** The surname tokens — everything before the comma the book writes. */
function surnameTokens(bookName: string): string[] {
  const comma = bookName.indexOf(",");
  // 54 of 1536 rows have no comma at all ("GREENE STEPHANIE",
  // "FAROOQ VAKIL"), and which half is the surname is unknowable. Requiring
  // a surname we cannot identify would silently exclude those rows, so they
  // fall back to requiring nothing beyond the containment rule.
  if (comma < 0) return [];
  return nameTokens(bookName.slice(0, comma));
}

function isPlaceholder(tokens: string[]): boolean {
  return tokens.length === 0 || PLACEHOLDER.has([...tokens].sort().join(" "));
}

function containsAll(haystack: Set<string>, needles: string[]): boolean {
  return needles.every((n) => haystack.has(n));
}

/**
 * Ranks the book against one printed name.
 *
 * Returns at most `limit` suggestions, `EXACT` before `PARTIAL` and higher
 * score first. An empty array means the printed name was unusable (too
 * long, too few real tokens) or nothing in the book came close — both of
 * which leave the review screen exactly as it is today.
 */
export function suggestCustomersByName(
  printedName: string | null | undefined,
  customers: CustomerNameRow[],
  limit = 3,
): CustomerNameSuggestion[] {
  if (!printedName || printedName.length > MAX_CHARS) return [];

  const printed = nameTokens(printedName);
  // One usable token is a surname or a given name on its own, and 34% of the
  // book shares a surname with someone. Nothing useful can come of it.
  if (printed.length < 2 || printed.length > MAX_TOKENS) return [];

  const printedSet = new Set(printed);
  const out: CustomerNameSuggestion[] = [];

  for (const c of customers) {
    const book = nameTokens(c.name);
    if (isPlaceholder(book) || book.length < 2) continue;

    const bookSet = new Set(book);
    const overlap = printed.filter((t) => bookSet.has(t)).length;
    // Two shared tokens is the floor: one is a bare surname collision.
    if (overlap < 2) continue;

    const bookInPrinted = containsAll(printedSet, book);
    const printedInBook = containsAll(bookSet, printed);
    if (!bookInPrinted && !printedInBook) continue;

    // When the book's name is the shorter one, containment already proves
    // the surname was printed. When the printed name is shorter — the book
    // holds a middle name or a second spouse the carrier omitted — the
    // surname must be there explicitly, or `JERRY MARILYN` would match
    // `ESTRADA, JERRY & MARILYN` on given names alone.
    if (!bookInPrinted && !containsAll(printedSet, surnameTokens(c.name))) continue;

    out.push({
      customerId: c.id,
      customerName: c.name,
      tier: bookInPrinted && printedInBook ? "EXACT" : "PARTIAL",
      score: overlap / Math.max(book.length, printed.length),
    });
  }

  out.sort((a, b) => {
    if (a.tier !== b.tier) return a.tier === "EXACT" ? -1 : 1;
    if (b.score !== a.score) return b.score - a.score;
    return a.customerName.localeCompare(b.customerName);
  });

  return out.slice(0, limit);
}

/** Review-queue wording for what the suggestions amount to. */
export function suggestionNote(suggestions: CustomerNameSuggestion[]): string | null {
  if (suggestions.length === 0) return null;

  const exact = suggestions.filter((s) => s.tier === "EXACT");
  // More than one exact hit is the duplicate-customer case the book really
  // has (`EMERY, LAURA` twice, `KIRCHHOFF, CINDY` three times). Saying so is
  // more useful than naming whichever one sorted first.
  if (exact.length > 1) {
    return `${exact.length} clientes tienen ese mismo nombre; elija cuál`;
  }
  if (exact.length === 1) {
    return `posible cliente por nombre: ${exact[0].customerName}`;
  }
  return `posibles clientes por nombre: ${suggestions.map((s) => s.customerName).join(", ")}`;
}
