import { describe, expect, it } from 'vitest';
import { SeededRNG, type RngStream } from '../src/core/SeededRNG';
import { ARCHETYPES } from '../src/data/archetypes';
import {
  assignListedName,
  editDistance,
  generateGuestList,
  lookupName,
  GUEST_LIST_TUNING as T,
  type GuestList,
  type NameMatch,
} from '../src/rules/guestList';

const stream = (seed: number) => new SeededRNG(seed).stream('guestList');

/**
 * A real stream with its FIRST `next()` forced to `roll`. assignListedName picks
 * an entry and then draws exactly one `next()` to choose its branch, so this
 * pins which branch runs while leaving every corruption draw genuinely random.
 * Without it the branches are only observable in aggregate, and aggregate
 * proportions are far too blunt to see a class leak.
 */
function forcedBranch(seed: number, roll: number): RngStream {
  const r = stream(seed);
  let spent = false;
  return {
    next: () => {
      if (spent) return r.next();
      spent = true;
      return roll;
    },
    int: (min, max) => r.int(min, max),
    pick: (arr) => r.pick(arr),
    chance: (p) => r.chance(p),
    weighted: (entries) => r.weighted(entries),
  };
}

const listFor = (seed: number, size = 12): GuestList => generateGuestList(stream(seed), size);

const classOf = (claimed: string, list: GuestList): NameMatch => lookupName(claimed, list).match;

/** Class mix over `n` claims, all with the same truth value. */
function sample(seed: number, list: GuestList, genuine: boolean, n = 500) {
  const rng = stream(seed);
  const counts: Record<NameMatch, number> = { exact: 0, near: 0, none: 0 };
  const claims: string[] = [];
  for (let i = 0; i < n; i++) {
    const claimed = assignListedName(rng, list, genuine);
    claims.push(claimed);
    counts[classOf(claimed, list)]++;
  }
  return { counts, claims };
}

describe('editDistance', () => {
  it('is zero for identical strings and for two empties', () => {
    expect(editDistance('', '')).toBe(0);
    expect(editDistance('Shazza Nguyen', 'Shazza Nguyen')).toBe(0);
  });

  it('against an empty string costs one edit per character', () => {
    expect(editDistance('', 'Doyle')).toBe(5);
    expect(editDistance('Doyle', '')).toBe(5);
  });

  it('charges one for a single insert, delete or substitute', () => {
    expect(editDistance('Kylie', 'Kyliee')).toBe(1);
    expect(editDistance('Kylie', 'Kyle')).toBe(1);
    expect(editDistance('Kylie', 'Kylia')).toBe(1);
  });

  it('charges two for a transposition — this is Levenshtein, not Damerau', () => {
    // Load-bearing for nearThreshold: a swapped pair is the most expensive
    // variant the module produces, and it has to stay inside the threshold.
    expect(editDistance('Marco', 'Mraco')).toBe(2);
    expect(editDistance('Brooke Vella', 'Brooke Vlela')).toBe(2);
  });

  it('ignores case and normalises whitespace', () => {
    expect(editDistance('BAZZA SMITH', 'bazza smith')).toBe(0);
    expect(editDistance('  Bazza   Smith  ', 'Bazza Smith')).toBe(0);
    expect(editDistance('Bazza\tSmith', 'bazza smith')).toBe(0);
  });

  it('is symmetric and agrees with hand-counted multi-edit cases', () => {
    expect(editDistance('Chantelle', 'Chantel')).toBe(2);
    expect(editDistance('Chantel', 'Chantelle')).toBe(2);
    expect(editDistance('Ngaio Taufa', 'Naigo Toufa')).toBe(3);
  });
});

describe('generateGuestList', () => {
  it('is deterministic: same seed → identical list', () => {
    expect(listFor(4207)).toEqual(listFor(4207));
    expect(listFor(4207)).not.toEqual(listFor(4208));
  });

  it('clamps the size to 8..14 however it is asked', () => {
    for (const asked of [-5, 0, 3, 8, 11, 14, 40, NaN]) {
      const n = generateGuestList(stream(77), asked).entries.length;
      expect(n).toBeGreaterThanOrEqual(T.minEntries);
      expect(n).toBeLessThanOrEqual(T.maxEntries);
    }
  });

  it('never repeats a name and draws only from the crowd the generator uses', () => {
    for (let seed = 0; seed < 60; seed++) {
      const names = listFor(seed, 14).entries.map((e) => e.name);
      expect(new Set(names).size).toBe(names.length);
      for (const name of names) expect(name).toMatch(/^[A-Za-z]+ [A-Za-z]+$/);
    }
  });

  it('keeps every pair of entries at least entrySeparation apart', () => {
    // The whole clipboard rests on this: if two entries were within the
    // threshold of one variant, that variant would point at both and the player
    // would be guessing rather than reading. The pools themselves cannot
    // promise it — Bazza and Dazza are one edit apart — so the list enforces it.
    // Asserted against the literal 4 as well as the constant: every other test
    // in this file reads T.nearThreshold on both sides of the comparison, so
    // without a literal somewhere a retune of the threshold is invisible.
    expect(T.nearThreshold).toBe(2);
    expect(T.entrySeparation).toBe(2 * T.nearThreshold);
    for (let seed = 0; seed < 120; seed++) {
      const { entries } = listFor(seed, 14);
      expect(entries.length).toBe(T.maxEntries);
      for (let i = 0; i < entries.length; i++) {
        for (let j = i + 1; j < entries.length; j++) {
          expect(editDistance(entries[i]!.name, entries[j]!.name)).toBeGreaterThan(4);
        }
      }
    }
  });

  it('hands out 0..3 plus-ones, mostly none', () => {
    const all = Array.from({ length: 40 }, (_, s) => listFor(s, 14).entries).flat();
    for (const e of all) {
      expect(Number.isInteger(e.plusOnes)).toBe(true);
      expect(e.plusOnes).toBeGreaterThanOrEqual(0);
      expect(e.plusOnes).toBeLessThanOrEqual(3);
    }
    const alone = all.filter((e) => e.plusOnes === 0).length / all.length;
    expect(alone).toBeGreaterThan(0.5);
  });
});

describe('lookupName', () => {
  it('classifies by distance and returns the closest entry', () => {
    const list = listFor(31);
    const target = list.entries[3]!;
    expect(lookupName(target.name, list)).toEqual({ match: 'exact', entry: target, distance: 0 });

    const near = lookupName(`${target.name.slice(0, 1)}${target.name}`, list);
    expect(near.match).toBe('near');
    expect(near.entry).toBe(target);
    expect(near.distance).toBe(1);
  });

  it('a miss reports its distance but carries no entry', () => {
    // Anything handed back here is a name the door UI would highlight.
    const r = lookupName('Kayden Kayden', listFor(31));
    expect(r.match).toBe('none');
    expect(r.entry).toBeUndefined();
    expect(r.distance).toBeGreaterThan(T.nearThreshold);
  });

  it('an empty clipboard matches nothing and reports infinite distance', () => {
    const r = lookupName('Shazza Nguyen', { entries: [] });
    expect(r.match).toBe('none');
    expect(r.entry).toBeUndefined();
    expect(r.distance).toBe(Number.POSITIVE_INFINITY);
  });

  it('survives whatever a name field can contain', () => {
    const list = listFor(5);
    const odd = ['', '   ', '\n\t', '!!!', "O'Brien-Smith (the tall one)", '???? ????', '🍢 kebab',
      'x'.repeat(5000), list.entries[0]!.name.toUpperCase(), `  ${list.entries[0]!.name}  `];
    for (const claimed of odd) {
      const r = lookupName(claimed, list);
      expect(['exact', 'near', 'none']).toContain(r.match);
      expect(r.distance).toBeGreaterThanOrEqual(0);
    }
    // Casing and stray whitespace must not cost a real guest their entry.
    expect(classOf(list.entries[0]!.name.toUpperCase(), list)).toBe('exact');
    expect(classOf(`  ${list.entries[0]!.name}  `, list)).toBe('exact');
  });
});

describe('assignListedName', () => {
  it('is deterministic and never throws on an empty clipboard', () => {
    const a = Array.from({ length: 20 }, () => 0);
    const runA = ((rng) => a.map(() => assignListedName(rng, { entries: [] }, true)))(stream(3));
    const runB = ((rng) => a.map(() => assignListedName(rng, { entries: [] }, true)))(stream(3));
    expect(runA).toEqual(runB);
    for (const name of runA) expect(name.length).toBeGreaterThan(0);
  });

  it('a genuine lister mostly lands NEAR, sometimes exact, rarely unreadable', () => {
    const list = listFor(11);
    const { counts, claims } = sample(101, list, true);
    const n = claims.length;
    expect(counts.near / n).toBeCloseTo(T.genuineNear, 1);
    expect(counts.exact / n).toBeCloseTo(T.genuineExact, 1);
    expect(counts.none / n).toBeCloseTo(T.genuineGarbled, 1);
  });

  it('a liar mostly lands NONE, but lifts a real entry often enough to matter', () => {
    const list = listFor(11);
    const { counts, claims } = sample(202, list, false);
    const n = claims.length;
    expect(counts.none / n).toBeCloseTo(T.liarInvented, 1);
    expect(counts.exact / n).toBeCloseTo(T.liarExact, 1);
    expect(counts.near / n).toBeCloseTo(T.liarNear, 1);
    expect(counts.exact).toBeGreaterThan(0);
  });

  it('every claim is exactly on the list, inside the threshold, or clearly off it', () => {
    const list = listFor(11);
    const names = new Set(list.entries.map((e) => e.name));
    for (const genuine of [true, false]) {
      for (const claimed of sample(303, list, genuine).claims) {
        const r = lookupName(claimed, list);
        // The only non-tautological halves: an 'exact' really is a verbatim row
        // (not a case/whitespace lookalike), and a 'near' really is not.
        if (r.match === 'exact') expect(names.has(claimed)).toBe(true);
        if (r.match === 'near') expect(names.has(claimed)).toBe(false);
      }
    }
  });

  it('EVERY branch produces the class it intended, not just on average', () => {
    // The spec's hard requirement, and the one thing aggregate proportions
    // cannot see. A near variant that comes back distance 0 is an accidental
    // 'exact'; a garble that lands inside the threshold is an accidental
    // 'near'. Both are rare enough (~1% and ~23% of raw corruptions
    // respectively) to hide inside a toBeCloseTo on the branch mix, and both
    // move the posteriors. nearVariant() and garbled() each re-check their own
    // output before returning; if either check is removed, this fails and the
    // proportion tests above do not.
    const list = listFor(11);
    const bands: Array<[string, boolean, number, number, NameMatch]> = [
      ['genuine → exact', true, 0, T.genuineExact, 'exact'],
      ['genuine → near', true, T.genuineExact, T.genuineExact + T.genuineNear, 'near'],
      ['genuine → garbled', true, T.genuineExact + T.genuineNear, 1, 'none'],
      ['liar → lifted exact', false, 0, T.liarExact, 'exact'],
      ['liar → near', false, T.liarExact, T.liarExact + T.liarNear, 'near'],
      ['liar → invented', false, T.liarExact + T.liarNear, 1, 'none'],
    ];

    bands.forEach(([label, genuine, lo, hi, expected], band) => {
      for (let i = 0; i < 600; i++) {
        // Each band gets its own seed range: the forced roll does not perturb
        // the corruption draws, so sharing seeds across bands would re-test one
        // set of variants six times and shrink the real sample to a sixth.
        // 600 distinct corruptions puts a ~1% leak beyond plausible deniability.
        const seed = band * 1000 + i;
        // Walk the whole band, not just its midpoint: the boundaries are where
        // an off-by-one in the cumulative comparisons would show up.
        const roll = lo + ((hi - lo) * (i % 20)) / 20;
        const claimed = assignListedName(forcedBranch(seed, roll), list, genuine);
        expect({ label, roll, claimed, got: classOf(claimed, list) }).toEqual({
          label,
          roll,
          claimed,
          got: expected,
        });
      }
    });
  });

  it('a near claim is never within the threshold of two different entries', () => {
    // entrySeparation is 2 * nearThreshold precisely so this holds: a variant
    // sits <= nearThreshold from its source, so a gap of merely nearThreshold+1
    // would let it land one edit from a neighbour and lookupName would hand the
    // door UI the wrong row, with someone else's plus-ones on it.
    for (let s = 0; s < 60; s++) {
      const rng = stream(s);
      const list = generateGuestList(rng, 14);
      for (let i = 0; i < 150; i++) {
        const claimed = assignListedName(rng, list, i % 2 === 0);
        if (classOf(claimed, list) !== 'near') continue;
        const within = list.entries.filter((e) => editDistance(claimed, e.name) <= T.nearThreshold);
        expect(within).toHaveLength(1);
      }
    }
  });

  it('the claimant prior matches the archetype table it was derived from', () => {
    // claimantGenuineRate is copied maths, and copied maths rots. If someone
    // retunes onGuestList/claimsGuestList in data/archetypes.ts, the posteriors
    // below move and this fails first.
    let claims = 0;
    let listed = 0;
    for (const a of ARCHETYPES) {
      const onList = a.chances.onGuestList ?? 0;
      const claimP = onList + (1 - onList) * (a.chances.claimsGuestList ?? 0);
      claims += a.weight * claimP;
      listed += a.weight * onList;
    }
    expect(listed / claims).toBeCloseTo(T.claimantGenuineRate, 2);
  });

  it('an exact match is a coin flip, near leans legit, none leans liar', () => {
    // THE test. Everything else in this file is scaffolding for it: the door is
    // only playable if the three match classes carry different, readable, and
    // never-certain odds. Nail these and the clipboard is a skill; let exact
    // drift to either extreme and it is either a lookup table or a dice roll.
    const rng = stream(9001);
    const list = generateGuestList(rng, 12);
    const seen: Record<NameMatch, { genuine: number; liar: number }> = {
      exact: { genuine: 0, liar: 0 },
      near: { genuine: 0, liar: 0 },
      none: { genuine: 0, liar: 0 },
    };

    for (let i = 0; i < 12000; i++) {
      const genuine = rng.chance(T.claimantGenuineRate);
      const bucket = seen[classOf(assignListedName(rng, list, genuine), list)];
      if (genuine) bucket.genuine++;
      else bucket.liar++;
    }

    const posterior = (m: NameMatch): number => {
      const b = seen[m];
      expect(b.genuine + b.liar).toBeGreaterThan(200);
      return b.genuine / (b.genuine + b.liar);
    };
    const exact = posterior('exact');
    const near = posterior('near');
    const none = posterior('none');

    expect(exact).toBeGreaterThan(0.42);
    expect(exact).toBeLessThan(0.58);
    expect(near).toBeGreaterThan(0.55);
    expect(none).toBeLessThan(0.1);
    // Ordered and, crucially, never certain in either direction: an honest
    // patron can read as a chancer and a chancer can read as honest.
    expect(none).toBeLessThan(exact);
    expect(exact).toBeLessThan(near);
    expect(near).toBeLessThan(1);
    expect(none).toBeGreaterThan(0);
  });
});
