import { describe, expect, it } from 'vitest';
import { SeededRNG, type RngStream } from '../src/core/SeededRNG';
import {
  challengesFor,
  perform,
  PASS_THRESHOLD,
  RIDDLES,
  type Challenge,
  type ChallengeKind,
} from '../src/rules/sobriety';
import type { Patron } from '../src/data/types';

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

const patron = (intoxication: number, tolerance = 0.5): Patron => ({
  id: 'p1',
  dollSeed: 1,
  archetype: 'punter',
  age: 24,
  idCard: { name: 'Shazza Vella', dob: '2002-03-04', expiry: '2029-03-04', photoSeed: 1 },
  outfit: [],
  intoxication,
  tolerance,
  flags: {},
});

const challenge = (kind: ChallengeKind, seed = 1): Challenge => {
  const found = challengesFor(stream(seed)).find((c) => c.kind === kind);
  if (!found) throw new Error(`no ${kind} challenge`);
  return found;
};

const ALPHABET = challenge('alphabet');
const RIDDLE = challenge('riddle');

/** Mean quality over n independent performances of the given patron shape. */
const meanQuality = (make: (rng: RngStream) => Patron, seed: number, n: number): number => {
  const rng = stream(seed);
  let total = 0;
  for (let i = 0; i < n; i++) total += perform(make(rng), ALPHABET, rng).quality;
  return total / n;
};

const passRate = (make: (rng: RngStream) => Patron, seed: number, n: number): number => {
  const rng = stream(seed);
  let passes = 0;
  for (let i = 0; i < n; i++) if (perform(make(rng), RIDDLE, rng).objectivelySober) passes++;
  return passes / n;
};

const inBand = (lo: number, hi: number) => (rng: RngStream) => patron(lo + rng.next() * (hi - lo));

/** n answers for one patron shape, so the degradation machinery can be inspected. */
const answersFor = (
  intoxication: number,
  tolerance: number,
  c: Challenge,
  seed: number,
  n: number,
): string[] => {
  const rng = stream(seed);
  const out: string[] = [];
  for (let i = 0; i < n; i++) out.push(perform(patron(intoxication, tolerance), c, rng).answer);
  return out;
};

/** Answers that recur verbatim are canned lines; typo'd ones essentially never repeat. */
const cannedShare = (answers: string[], key: (a: string) => string, minRepeats = 5): number => {
  const counts = new Map<string, number>();
  for (const a of answers) counts.set(key(a), (counts.get(key(a)) ?? 0) + 1);
  let canned = 0;
  for (const c of counts.values()) if (c >= minRepeats) canned += c;
  return canned / answers.length;
};

describe('perform', () => {
  it('is deterministic: same seed + same patron → identical Performance', () => {
    const a = perform(patron(0.55), RIDDLE, stream(99));
    const b = perform(patron(0.55), RIDDLE, stream(99));
    expect(a).toEqual(b);
  });

  it('average quality falls monotonically across drunk stages', () => {
    const sober = meanQuality(inBand(0, 0.2), 3, 200);
    const tipsy = meanQuality(inBand(0.2, 0.45), 3, 200);
    const messy = meanQuality(inBand(0.65, 0.85), 3, 200);
    const maggot = meanQuality(inBand(0.85, 1), 3, 200);
    expect(sober).toBeGreaterThan(tipsy);
    expect(tipsy).toBeGreaterThan(messy);
    expect(messy).toBeGreaterThan(maggot);
  });

  it('a sober patron passes objectively in >95% of rolls', () => {
    expect(passRate(() => patron(0, 0.5), 5, 200)).toBeGreaterThan(0.95);
  });

  it('a maggot passes objectively in <5% of rolls', () => {
    expect(passRate(() => patron(0.95, 0.5), 7, 200)).toBeLessThan(0.05);
  });

  it('the loose/messy band stays genuinely ambiguous', () => {
    const rate = passRate(inBand(0.45, 0.85), 11, 300);
    expect(rate).toBeGreaterThan(0.15);
    expect(rate).toBeLessThan(0.85);
  });

  it('quality stays in 0..1 and the answer is never empty', () => {
    const rng = stream(13);
    for (let i = 0; i < 500; i++) {
      const p = patron(rng.next(), rng.next());
      const perf = perform(p, i % 2 === 0 ? ALPHABET : RIDDLE, rng);
      expect(perf.quality).toBeGreaterThanOrEqual(0);
      expect(perf.quality).toBeLessThanOrEqual(1);
      expect(perf.answer.length).toBeGreaterThan(0);
      expect(perf.objectivelySober).toBe(perf.quality >= PASS_THRESHOLD);
    }
  });

  it('a flawless roll reproduces the ideal answer exactly', () => {
    const rng = stream(17);
    let checked = 0;
    for (let i = 0; i < 400; i++) {
      const c = i % 2 === 0 ? ALPHABET : RIDDLE;
      const perf = perform(patron(0, 1), c, rng);
      if (perf.quality < 0.99) continue;
      expect(perf.answer).toBe(c.ideal);
      checked++;
    }
    expect(checked).toBeGreaterThan(0);
  });

  it('a messy patron almost never gets the recital out clean', () => {
    const rng = stream(19);
    let clean = 0;
    for (let i = 0; i < 200; i++) {
      if (perform(patron(0.75, 0.4), ALPHABET, rng).answer === ALPHABET.ideal) clean++;
    }
    expect(clean / 200).toBeLessThan(0.05);
  });

  it('tolerance helps at equal intoxication', () => {
    const hard = meanQuality(() => patron(0.7, 0.9), 23, 200);
    const soft = meanQuality(() => patron(0.7, 0.1), 23, 200);
    expect(hard).toBeGreaterThan(soft);
  });

  it('PASS_THRESHOLD is 0.6 and the comparison is inclusive', () => {
    expect(PASS_THRESHOLD).toBe(0.6);
    // A stub stream that always sits dead centre => zero jitter, so quality is
    // exactly 1 - intoxication. 0.4 lands us precisely on the threshold.
    const flat: RngStream = {
      next: () => 0.5,
      int: (min) => min,
      pick: <T,>(arr: readonly T[]): T => arr[0] as T,
      chance: () => false,
      weighted: <T,>(e: ReadonlyArray<readonly [T, number]>): T => e[0]![0],
    };
    const perf = perform(patron(0.4, 0), RIDDLE, flat);
    expect(perf.quality).toBe(PASS_THRESHOLD);
    expect(perf.objectivelySober).toBe(true);
  });

  it('a maggot never reads as objectively sober, at any tolerance', () => {
    // The honesty oracle must not be rescuable by seasoning. Sweep the whole
    // maggot band against the whole tolerance range, including the corners.
    const rng = stream(29);
    let worst = 0;
    let worstAt = '';
    let sober = 0;
    for (let i = 0; i <= 30; i++) {
      const intox = 0.85 + (i / 30) * 0.15;
      for (let t = 0; t <= 20; t++) {
        const tol = t / 20;
        for (let k = 0; k < 40; k++) {
          const perf = perform(patron(intox, tol), RIDDLE, rng);
          if (perf.objectivelySober) sober++;
          if (perf.quality > worst) {
            worst = perf.quality;
            worstAt = `intoxication ${intox.toFixed(3)}, tolerance ${tol.toFixed(2)}`;
          }
        }
      }
    }
    expect({ sober, worstAt }).toEqual({ sober: 0, worstAt });
    expect(worst).toBeLessThan(PASS_THRESHOLD);
  });

  it('the alphabet recital loses its reach as quality falls, then gives up', () => {
    const letters = (a: string): number => (a.match(/[A-Z]/g) ?? []).length;
    const meanLetters = (intox: number, seed: number): number => {
      const as = answersFor(intox, 0.5, ALPHABET, seed, 300);
      return as.reduce((s, a) => s + letters(a), 0) / as.length;
    };
    const nearSober = meanLetters(0.1, 41);
    const loose = meanLetters(0.55, 43);
    const maggot = meanLetters(0.95, 47);

    expect(nearSober).toBeGreaterThan(24);
    expect(loose).toBeLessThan(nearSober - 3);
    expect(maggot).toBeLessThan(loose - 3);
    expect(maggot).toBeLessThan(14); // gave up well before Z..A ran out

    // The bail is a canned line, so the tails repeat verbatim; a merely
    // mangled recital would produce ~300 distinct endings.
    const tails = answersFor(0.95, 0.5, ALPHABET, 53, 300);
    expect(cannedShare(tails, (a) => a.slice(-14))).toBeGreaterThan(0.5);
  });

  it('riddle answers go from typo to outright non-answer as quality falls', () => {
    const ideal = RIDDLE.ideal;

    // Mid-band: recognisably an attempt at the answer, but mangled.
    const mid = answersFor(0.55, 0.5, RIDDLE, 59, 400);
    const mangled = mid.filter((a) => a !== ideal && a.length >= ideal.length - 2);
    expect(mangled.length / mid.length).toBeGreaterThan(0.25);

    // Bottom: getting it out verbatim is a rare fluke (~0.3%, and that fluke is
    // deliberate — the minigame is not a reliable oracle), and a real share bail
    // to canned non-answers.
    const bottom = answersFor(0.99, 0.5, RIDDLE, 61, 2000);
    expect(bottom.filter((a) => a === ideal).length / bottom.length).toBeLessThan(0.02);
    expect(cannedShare(bottom, (a) => a, 20)).toBeGreaterThan(0.2);
    // ...drawn from a pool, not one stock line.
    const counts = new Map<string, number>();
    for (const a of bottom) counts.set(a, (counts.get(a) ?? 0) + 1);
    const pool = [...counts.values()].filter((c) => c >= 20).length;
    expect(pool).toBeGreaterThanOrEqual(3);
  });
});

describe('challenges', () => {
  it('challengesFor returns exactly one alphabet and one riddle from RIDDLES', () => {
    for (let seed = 0; seed < 25; seed++) {
      const cs = challengesFor(stream(seed));
      expect(cs.map((c) => c.kind).sort()).toEqual(['alphabet', 'riddle']);
      const riddle = cs.find((c) => c.kind === 'riddle');
      expect(RIDDLES.some((r) => r.prompt === riddle?.prompt && r.ideal === riddle?.ideal)).toBe(true);
    }
  });

  it('the alphabet ideal is exactly the reversed alphabet', () => {
    const reversed = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').reverse().join('');
    expect(ALPHABET.ideal).toBe(reversed);
  });

  it('RIDDLES has 8 usable, uniquely-prompted entries', () => {
    expect(RIDDLES).toHaveLength(8);
    for (const r of RIDDLES) {
      expect(r.prompt.trim().length).toBeGreaterThan(0);
      expect(r.ideal.trim().length).toBeGreaterThan(0);
    }
    expect(new Set(RIDDLES.map((r) => r.prompt)).size).toBe(RIDDLES.length);
  });
});
