import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import { drunkify, typedLength, wobbleAt, type RngLike } from '../src/ui/typo';

/** Fresh stream every call — drunkify consumes draws, so tests must not share state. */
function stream(seed: number, name = 'typo'): RngLike {
  return new SeededRNG(seed).stream(name);
}

const LINE =
  "Yeah nah mate the line's round the corner, hop in and grab a schooner before last drinks at half two.";

// Long enough that two different seeds colliding on an identical mangle is not plausible.
const CORPUS = Array.from({ length: 6 }, () => LINE).join(' ');

function levenshtein(a: string, b: string): number {
  let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
  for (let i = 1; i <= a.length; i++) {
    const row = [i];
    for (let j = 1; j <= b.length; j++) {
      const cost = a[i - 1] === b[j - 1] ? 0 : 1;
      row.push(Math.min(row[j - 1]! + 1, prev[j]! + 1, prev[j - 1]! + cost));
    }
    prev = row;
  }
  return prev[b.length]!;
}

const nonLetters = (s: string): string => s.replace(/[a-zA-Z]/g, '');

/**
 * Matches exactly those strings that keep `input`'s non-letter skeleton verbatim
 * while every letter run has become zero or more *letters*. Comparing against this
 * — rather than stripping non-letters first, which makes the assertion vacuous —
 * is what catches a letter position emitting something that is not a letter.
 */
function letterShape(input: string): RegExp {
  const MARK = '\u0000'; // cannot occur in dialogue text, so it is a safe placeholder
  const src = input
    .replace(/[a-zA-Z]+/g, MARK)
    .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
    .split(MARK)
    .join('[a-zA-Z]*');
  return new RegExp(`^${src}$`);
}

describe('drunkify', () => {
  it('is byte-identical for the same text, drunkenness and seed', () => {
    const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1234) });
    const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1234) });
    expect(a).toBe(b);
    expect(a).not.toBe(CORPUS); // sanity: 0.8 actually mangles something
  });

  it('diverges between seeds', () => {
    const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(1) });
    const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(2) });
    expect(a).not.toBe(b);
  });

  it('is stream-scoped, not seed-scoped', () => {
    const a = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(7, 'patron:dialogue') });
    const b = drunkify(CORPUS, { drunkenness: 0.8, rng: stream(7, 'patron:heckle') });
    expect(a).not.toBe(b);
  });

  it('leaves text untouched at drunkenness 0, without consuming draws', () => {
    const rng = stream(99);
    expect(drunkify(LINE, { drunkenness: 0, rng })).toBe(LINE);
    expect(rng.next()).toBe(stream(99).next());
  });

  it('leaves text untouched at or below the 0.25 sober floor', () => {
    for (const d of [0.05, 0.1, 0.2, 0.249, 0.25]) {
      expect(drunkify(CORPUS, { drunkenness: d, rng: stream(5) })).toBe(CORPUS);
    }
    // Just above the floor the odds per letter are tiny, so check across seeds
    // that *something* slips — the threshold is a ramp, not a hard cut.
    const seeds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
    const mangled = seeds.filter((s) => drunkify(CORPUS, { drunkenness: 0.3, rng: stream(s) }) !== CORPUS);
    expect(mangled.length).toBeGreaterThan(0);
  });

  it('clamps drunkenness into 0..1', () => {
    expect(drunkify(CORPUS, { drunkenness: 4, rng: stream(42) })).toBe(
      drunkify(CORPUS, { drunkenness: 1, rng: stream(42) }),
    );
    expect(drunkify(CORPUS, { drunkenness: -3, rng: stream(42) })).toBe(CORPUS);
  });

  it('treats a non-finite drunkenness as sober, without consuming draws', () => {
    for (const d of [NaN, Infinity, -Infinity]) {
      const rng = stream(77);
      expect(drunkify(CORPUS, { drunkenness: d, rng })).toBe(CORPUS);
      expect(rng.next()).toBe(stream(77).next());
    }
  });

  it('never substitutes or drops non-letters, even at maximum', () => {
    const punctuated = 'Oi! 3 IDs, 2 mates — $15 cover (cash only)?? "no wuckas"...\tline 4\n';
    for (const seed of [11, 12, 13, 14, 15]) {
      const out = drunkify(punctuated, { drunkenness: 1, rng: stream(seed) });
      expect(nonLetters(out)).toBe(nonLetters(punctuated));
    }
  });

  it('only ever emits letters where letters were', () => {
    const punctuated = 'Oi! 3 IDs, 2 mates — $15 cover (cash only)?? "no wuckas"...\tline 4\n';
    for (const source of [CORPUS, punctuated]) {
      const shape = letterShape(source);
      for (const seed of [808, 809, 810]) {
        expect(drunkify(source, { drunkenness: 1, rng: stream(seed) })).toMatch(shape);
      }
    }
  });

  it('mangles substantially more the drunker the speaker is', () => {
    // Statistical, so average over several seeds and assert a loose ratio.
    let tipsy = 0;
    let maggot = 0;
    for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) {
      tipsy += levenshtein(CORPUS, drunkify(CORPUS, { drunkenness: 0.4, rng: stream(seed) }));
      maggot += levenshtein(CORPUS, drunkify(CORPUS, { drunkenness: 1, rng: stream(seed) }));
    }
    expect(tipsy).toBeGreaterThan(0);
    expect(maggot).toBeGreaterThan(tipsy * 2);
  });
});

describe('typedLength', () => {
  it('reveals nothing at zero elapsed', () => {
    expect(typedLength(LINE, 0, 28)).toBe(0);
    expect(typedLength(LINE, 0, 28, 1)).toBe(0);
  });

  it('reaches but never exceeds the full length', () => {
    expect(typedLength(LINE, LINE.length * 28, 28)).toBe(LINE.length);
    expect(typedLength(LINE, 10_000_000, 28, 1)).toBe(LINE.length);
    expect(typedLength('', 10_000, 28)).toBe(0);
  });

  it('is monotonic in elapsed time', () => {
    let prev = -1;
    for (let ms = 0; ms <= 4000; ms += 17) {
      const n = typedLength(LINE, ms, 28, 0.7);
      expect(n).toBeGreaterThanOrEqual(prev);
      prev = n;
    }
  });

  it('makes drunk speakers type slower', () => {
    const sober = typedLength(LINE, 900, 28, 0);
    const drunk = typedLength(LINE, 900, 28, 1);
    expect(drunk).toBeLessThan(sober);
    expect(typedLength(LINE, 900, 28, 0.5)).toBeLessThan(sober);
  });

  it('clamps drunkenness like drunkify does', () => {
    expect(typedLength(LINE, 900, 28, 5)).toBe(typedLength(LINE, 900, 28, 1));
    expect(typedLength(LINE, 900, 28, -5)).toBe(typedLength(LINE, 900, 28, 0));
  });

  it('short-circuits to full length when charMs is non-positive', () => {
    expect(typedLength(LINE, 0, 0)).toBe(LINE.length);
    expect(typedLength(LINE, 0, -5, 1)).toBe(LINE.length);
  });

  // DialogueBox accumulates `elapsed += delta`, so a NaN that escaped here would
  // stall the reveal for the rest of the night rather than for one frame.
  it('treats a non-finite elapsed or drunkenness as zero', () => {
    expect(typedLength(LINE, NaN, 28)).toBe(0);
    expect(typedLength(LINE, NaN, 28, 1)).toBe(0);
    expect(typedLength(LINE, 900, 28, NaN)).toBe(typedLength(LINE, 900, 28, 0));
  });
});

describe('wobbleAt', () => {
  it('is flat when sober', () => {
    for (let i = 0; i < 20; i++) expect(wobbleAt(i, 0, i * 0.3)).toBe(0);
    expect(wobbleAt(3, -1, 1.2)).toBe(0);
  });

  it('stays within the 1.6px amplitude at maximum', () => {
    for (let i = 0; i < 200; i++) {
      for (const phase of [0, 0.7, 2.5, 6.1]) {
        expect(Math.abs(wobbleAt(i, 1, phase))).toBeLessThanOrEqual(1.6);
        expect(Math.abs(wobbleAt(i, 4, phase))).toBeLessThanOrEqual(1.6); // clamped
      }
    }
  });

  it('scales with drunkenness', () => {
    // Index/phase chosen so sin() is well away from a zero crossing.
    const at = (d: number): number => Math.abs(wobbleAt(0, d, Math.PI / 2));
    expect(at(0.5)).toBeGreaterThan(at(0.25));
    expect(at(1)).toBeGreaterThan(at(0.5));
  });

  // Same reasoning as typedLength: phase is accumulated, and a NaN reaching
  // text.y makes Phaser drop the glyph silently instead of erroring.
  it('treats a non-finite phase or drunkenness as zero', () => {
    expect(wobbleAt(0, 1, NaN)).toBe(0);
    expect(wobbleAt(5, 1, Infinity)).toBe(0);
    expect(wobbleAt(5, NaN, 1.2)).toBe(0);
  });

  it('is deterministic for the same index, drunkenness and phase', () => {
    expect(wobbleAt(7, 0.6, 1.25)).toBe(wobbleAt(7, 0.6, 1.25));
    expect(wobbleAt(8, 0.6, 1.25)).not.toBe(wobbleAt(7, 0.6, 1.25));
  });
});
