// The guest-list clipboard (design §3.1). A handwritten list, and a queue of
// people claiming to be on it.
//
// The mechanic is an inversion: a name that ALMOST matches is usually legit —
// the doorman's handwriting is rubbish and half of them gave a nickname — while
// a name that matches the clipboard EXACTLY, said with confidence, is as often
// as not lifted off someone who walked in an hour ago.
//
// Nothing here is ever certain, on purpose. Every match class can be produced by
// an honest patron AND by a liar; the numbers below only move the player from a
// coin flip to better than chance. Do not "fix" that by making a class a
// guaranteed tell — a guaranteed tell turns the clipboard into a lookup table
// and there is no decision left to make.

import type { RngStream } from '../core/SeededRNG';

export interface GuestListEntry {
  name: string;
  plusOnes: number;
}

export interface GuestList {
  entries: GuestListEntry[];
}

export type NameMatch = 'exact' | 'near' | 'none';

export interface NameLookup {
  match: NameMatch;
  entry?: GuestListEntry;
  /** Edits to the CLOSEST entry, whatever the class. Infinity on an empty list. */
  distance: number;
}

export const GUEST_LIST_TUNING = {
  minEntries: 8,
  maxEntries: 14,

  // Two, not three. Every near variant this module produces is at most two edits
  // from its entry (a transposition is the expensive one — plain Levenshtein
  // charges 2 for it), so 2 is the tightest threshold that still catches every
  // plausible misreading. Three would only admit corruptions nothing generates,
  // while making the separation constraint below harder to satisfy.
  nearThreshold: 2,

  // How far apart two entries on one clipboard must be. It is 2 * nearThreshold,
  // and the factor of two is load-bearing rather than cautious: a near variant
  // sits up to nearThreshold from its source, so by the triangle inequality only
  // a gap of MORE than twice the threshold guarantees that variant is still
  // outside the threshold of every other entry. At a gap of merely
  // nearThreshold + 1 a two-edit variant can land one edit from a neighbour, and
  // lookupName then hands the door UI the wrong row — the player highlights a
  // stranger and the +1 count they read belongs to someone else.
  entrySeparation: 4,

  /** Mostly parties of one. The +1 that arrives as +6 is the door's problem. */
  plusOneWeights: [
    [0, 62],
    [1, 24],
    [2, 9],
    [3, 5],
  ] as ReadonlyArray<readonly [number, number]>,

  // Claim mix for a patron who really is on the list. They mostly give a name
  // the clipboard half-agrees with; sometimes it is written down perfectly; and
  // sometimes the doorman butchered it past recognition and the clipboard is no
  // help to an honest person at all.
  genuineExact: 0.3,
  genuineNear: 0.58,
  genuineGarbled: 0.12,

  // Claim mix for a liar. Mostly a name that simply is not there. Sometimes they
  // overheard a real one and got it slightly wrong; sometimes they got it dead
  // right, which is the entire mechanic.
  liarExact: 0.06,
  liarNear: 0.06,
  liarInvented: 0.88,

  // Share of guest-list CLAIMANTS who are telling the truth, derived from the
  // spawn weights and chances in data/archetypes.ts (influencers and regulars
  // are the only archetypes that are ever really on the list; punters, finance
  // bros and the owner's mate only ever claim). Liars outnumber listers about
  // five to one, which is why the mixes above are lopsided — they exist to drag
  // the posteriors back to something a player can read.
  //
  // Posteriors at this prior, asserted in tests/guestList.test.ts:
  //   P(genuine | exact) ≈ 0.50 — a coin flip, and it must stay one
  //   P(genuine | near)  ≈ 0.66 — leans legit
  //   P(genuine | none)  ≈ 0.03 — leans liar, but is not proof
  // Re-derive all four numbers if archetypes.ts changes its guest-list chances.
  claimantGenuineRate: 0.168,

  /** Random draws before falling back to a deterministic sweep of the pools. */
  drawAttempts: 24,
  /** Corruption rolls before falling back to a guaranteed one-edit variant. */
  variantAttempts: 12,
  /** Edits applied to garble a name past recognition. */
  garbleEdits: 4,
  /** Chance a near variant is a transposition rather than one or two cheap edits. */
  transposeChance: 0.3,
  /** Chance a cheap-edit variant gets a second edit on top. */
  secondEditChance: 0.35,
};

// Deliberately duplicated from src/patrons/generator.ts, which keeps its pools
// module-private and is frozen to this lane. The failure mode of duplication is
// the two lists drifting apart, and a guest list drawn from names the crowd
// never uses reads as obviously-not-the-crowd — a content bug, not a crash. If
// generator.ts ever exports these, delete these copies and import them.
const FIRST = ['Shazza', 'Dazza', 'Bazza', 'Kylie', 'Jai', 'Tahlia', 'Lachlan', 'Brooke', 'Cooper', 'Mia', 'Ngaio', 'Con', 'Duc', 'Sofia', 'Marco', 'Aroha', 'Blake', 'Chantelle', 'Rhys', 'Imogen'] as const;
const LAST = ['Nguyen', 'Smith', 'Papadopoulos', 'Chen', 'OBrien', 'Kowalski', 'Singh', 'Taufa', 'Romano', 'Petersen', 'Doyle', 'Vella', 'Karim', 'Marsh', 'Duffy'] as const;

const POOL_SIZE = FIRST.length * LAST.length;

// Corruptions only ever land on lowercase letters, which in these pools means
// every capital survives. A variant is meant to read as a name someone spelled
// wrong; touching the initials gives you "oRmano" and " arim", which read as a
// broken string instead — the player stops thinking about handwriting and starts
// thinking about the program.
const EDITABLE = /[a-z]/;

/** a→e→i→o→u→a. Uppercase is absent for the same reason. */
const VOWEL_RING: Record<string, string> = { a: 'e', e: 'i', i: 'o', o: 'u', u: 'a' };

const normalise = (s: string): string => s.replace(/\s+/g, ' ').trim().toLowerCase();

export function editDistance(a: string, b: string): number {
  const s = normalise(a);
  const t = normalise(b);
  if (s === t) return 0;
  if (s.length === 0) return t.length;
  if (t.length === 0) return s.length;

  // Two rows rather than the full matrix: this runs a few hundred times per
  // claimant, because every variant is verified against every entry.
  let prev = Array.from({ length: t.length + 1 }, (_, i) => i);
  let curr = new Array<number>(t.length + 1).fill(0);

  for (let i = 1; i <= s.length; i++) {
    curr[0] = i;
    const sc = s[i - 1];
    for (let j = 1; j <= t.length; j++) {
      const cost = sc === t[j - 1] ? 0 : 1;
      curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1, prev[j - 1]! + cost);
    }
    [prev, curr] = [curr, prev];
  }
  return prev[t.length]!;
}

export function lookupName(claimed: string, list: GuestList): NameLookup {
  let closest: GuestListEntry | undefined;
  let distance = Number.POSITIVE_INFINITY;

  for (const entry of list.entries) {
    const d = editDistance(claimed, entry.name);
    if (d < distance) {
      distance = d;
      closest = entry;
      if (d === 0) break;
    }
  }

  if (closest === undefined) return { match: 'none', distance };

  const match: NameMatch =
    distance === 0 ? 'exact' : distance <= GUEST_LIST_TUNING.nearThreshold ? 'near' : 'none';

  // A 'none' carries no entry even though one was found. `distance` still
  // reports how close it got, but handing back the row would invite the door UI
  // to highlight a name the patron has nothing to do with — and any caller doing
  // `if (result.entry)` would be admitting strangers.
  return match === 'none' ? { match, distance } : { match, entry: closest, distance };
}

const poolName = (rng: RngStream): string => `${rng.pick(FIRST)} ${rng.pick(LAST)}`;

/**
 * True iff `name` is more than `minDistance` edits from every entry.
 * At `nearThreshold` that means "reads as a miss"; at `entrySeparation` it means
 * "safe to add to the clipboard". Infinity on an empty list, so both hold.
 */
const isSeparated = (name: string, list: GuestList, minDistance: number): boolean =>
  lookupName(name, list).distance > minDistance;

// A pool name the clipboard has no opinion about. Random draws first so the mix
// stays natural, then an exhaustive sweep so this always terminates: the pools
// hold 300 combinations and a list holds at most 14, so the sweep only comes up
// empty for a list far longer than this game builds.
function drawSeparatedName(
  rng: RngStream,
  list: GuestList,
  minDistance: number,
): string | undefined {
  for (let i = 0; i < GUEST_LIST_TUNING.drawAttempts; i++) {
    const name = poolName(rng);
    if (isSeparated(name, list, minDistance)) return name;
  }
  const start = rng.int(0, POOL_SIZE - 1);
  for (let k = 0; k < POOL_SIZE; k++) {
    const idx = (start + k) % POOL_SIZE;
    const name = `${FIRST[idx % FIRST.length]!} ${LAST[Math.floor(idx / FIRST.length)]!}`;
    if (isSeparated(name, list, minDistance)) return name;
  }
  return undefined;
}

export function generateGuestList(rng: RngStream, size: number): GuestList {
  const T = GUEST_LIST_TUNING;
  const asked = Number.isFinite(size) ? Math.round(size) : T.minEntries;
  const wanted = Math.max(T.minEntries, Math.min(T.maxEntries, asked));
  const entries: GuestListEntry[] = [];

  for (let i = 0; i < wanted; i++) {
    // Entries are kept entrySeparation apart so a near variant can only ever
    // point at one of them. Two Dazzas a couple of edits apart would make the
    // clipboard ambiguous in a way the player cannot reason about — and the
    // pools genuinely contain such pairs (Bazza/Dazza), which is why this is
    // enforced per list rather than assumed of the pools.
    const name = drawSeparatedName(rng, { entries }, T.entrySeparation);
    // A short list is a wobble; a hang is a crash. See drawSeparatedName.
    if (name === undefined) break;
    entries.push({ name, plusOnes: rng.weighted(T.plusOneWeights) });
  }

  return { entries };
}

function editableIndices(s: string): number[] {
  const out: number[] = [];
  for (let i = 0; i < s.length; i++) if (EDITABLE.test(s[i]!)) out.push(i);
  return out;
}

/**
 * One edit: a dropped letter, a doubled letter, or a vowel gone sideways.
 * The three kinds are not equally likely — a consonant has no vowel-ring entry,
 * so kind 2 falls back to doubling and consonants double twice as often as they
 * drop. That is a deliberate lean: a doubled consonant ("Kowalskki") reads as
 * handwriting, a dropped one ("Kowaski") reads more like a different name.
 */
function oneEdit(rng: RngStream, s: string): string {
  const at = editableIndices(s);
  // Two letters minimum, so a name can never be edited down to nothing.
  if (at.length < 2) return s;
  const i = rng.pick(at);
  const ch = s[i]!;
  const kind = rng.int(0, 2);
  if (kind === 0) return s.slice(0, i) + s.slice(i + 1);
  if (kind === 1) return s.slice(0, i) + ch + s.slice(i);
  const swapped = VOWEL_RING[ch];
  return swapped === undefined
    ? s.slice(0, i) + ch + s.slice(i)
    : s.slice(0, i) + swapped + s.slice(i + 1);
}

/** Two adjacent letters the wrong way round — two edits under plain Levenshtein. */
function transpose(rng: RngStream, s: string): string {
  const at = editableIndices(s).filter((i) => EDITABLE.test(s[i + 1] ?? '') && s[i] !== s[i + 1]);
  if (at.length === 0) return s;
  const i = rng.pick(at);
  return s.slice(0, i) + s[i + 1]! + s[i]! + s.slice(i + 2);
}

function corrupt(rng: RngStream, name: string): string {
  // A transposition already spends the whole two-edit budget, so it never gets
  // stacked with anything. Two edits on top of it would be the garbled branch.
  if (rng.chance(GUEST_LIST_TUNING.transposeChance)) return transpose(rng, name);
  const once = oneEdit(rng, name);
  return rng.chance(GUEST_LIST_TUNING.secondEditChance) ? oneEdit(rng, once) : once;
}

/** Doubling a letter is exactly one insertion, always. The safety net. */
const doubledLetter = (name: string): string =>
  name.length > 1 ? `${name.slice(0, 2)}${name.slice(1)}` : `${name}${name}`;

function nearVariant(rng: RngStream, name: string, list: GuestList): string {
  for (let i = 0; i < GUEST_LIST_TUNING.variantAttempts; i++) {
    const candidate = corrupt(rng, name);
    // Two cheap edits can cancel each other out (a drop and a double of the
    // same letter), and a transposition on a short name can land somewhere
    // unexpected. Unverified, roughly 1% of corruptions come back distance 0 —
    // small, but it lands entirely in 'exact', the rarest and most load-bearing
    // class. The class is what the caller was promised, so it is verified.
    if (lookupName(candidate, list).match === 'near') return candidate;
  }
  return doubledLetter(name);
}

// The other half of "bad handwriting": the name went down so badly that the
// clipboard is no use to someone who is genuinely on it. This is the honest
// person the system fails, and the game never tells the player it happened
// (§4.3) — from the door it is indistinguishable from a chancer.
function garbled(rng: RngStream, name: string, list: GuestList): string {
  const T = GUEST_LIST_TUNING;
  for (let i = 0; i < T.variantAttempts; i++) {
    let candidate = name;
    for (let e = 0; e < T.garbleEdits; e++) candidate = oneEdit(rng, candidate);
    // Only "reads as a miss" is required here, not clipboard-entry separation:
    // this name is never going on the list, it just has to classify as 'none'.
    // Verified rather than assumed — four edits still land inside the threshold
    // about a quarter of the time, and an unverified garble would quietly leak
    // into 'near' and drag the posteriors with it.
    if (isSeparated(candidate, list, T.nearThreshold)) return candidate;
  }
  // Failing that, they gave the name of the mate who actually made the booking.
  return drawSeparatedName(rng, list, T.nearThreshold) ?? doubledLetter(name);
}

/**
 * The name a claiming patron gives. `genuine` is the truth — `flags.onGuestList`
 * at the call site — and is never visible to the player, only its consequences.
 */
export function assignListedName(rng: RngStream, list: GuestList, genuine: boolean): string {
  const T = GUEST_LIST_TUNING;
  // An empty clipboard: nothing to be on and nothing to lift, so everyone reads
  // as a chancer. Guarded because rng.pick throws on an empty array.
  if (list.entries.length === 0) {
    return drawSeparatedName(rng, list, T.nearThreshold) ?? poolName(rng);
  }

  const entry = rng.pick(list.entries);
  const roll = rng.next();

  if (genuine) {
    if (roll < T.genuineExact) return entry.name;
    if (roll < T.genuineExact + T.genuineNear) return nearVariant(rng, entry.name, list);
    return garbled(rng, entry.name, list);
  }

  if (roll < T.liarExact) return entry.name;
  if (roll < T.liarExact + T.liarNear) return nearVariant(rng, entry.name, list);
  return drawSeparatedName(rng, list, T.nearThreshold) ?? garbled(rng, entry.name, list);
}
