import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator';
import { OUTFIT_VOCAB } from '../src/data/outfits';
import { drunkStage } from '../src/rules/drunk';
import type { DrunkStage, OutfitLayer, OutfitSlot, Patron } from '../src/data/types';
import {
  ZONE_BANDS,
  ZONE_ORDER,
  describeEyes,
  describeSlot,
  describeStance,
  describeZone,
  greetingIndex,
  hasWordFor,
  type InspectZone,
} from '../src/scenes/door/inspect';

const BASE_OUTFIT: readonly OutfitLayer[] = [
  { slot: 'shoes', type: 'boot', colour: 'brown' },
  { slot: 'legs', type: 'jeans', colour: 'denim' },
  { slot: 'top', type: 'tee', colour: 'red' },
  { slot: 'outer', type: 'hoodie', colour: 'green' },
  { slot: 'hair', type: 'mullet', colour: 'brown' },
  { slot: 'accessory', type: 'cap', colour: 'navy' },
];

type SlotOverrides = Partial<Record<OutfitSlot, Partial<OutfitLayer>>>;

const patron = (overrides: SlotOverrides = {}, intoxication = 0): Patron => ({
  id: 'p-test',
  dollSeed: 12345,
  archetype: 'punter',
  age: 25,
  idCard: { name: 'Shazza Vella', dob: '2001-03-02', expiry: '2030-01-01', photoSeed: 12345 },
  outfit: BASE_OUTFIT.map((l) => ({ ...l, ...overrides[l.slot] })),
  intoxication,
  tolerance: 0.5,
  flags: {},
});

const slots = Object.keys(OUTFIT_VOCAB) as OutfitSlot[];
const NIGHT = new Date('2026-07-18T00:00:00');

describe('describeSlot vocabulary coverage', () => {
  it('has plain-English words for every type in OUTFIT_VOCAB', () => {
    // Exact, not heuristic: a garment added to the vocabulary without a word
    // here would otherwise reach the player as a raw id, and the tooltips are
    // the only channel through which four of the dress-code rules are readable.
    const missing: string[] = [];
    for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
      for (const def of OUTFIT_VOCAB[slot]) {
        if (!hasWordFor(slot, def.type)) missing.push(`${slot}.${def.type}`);
      }
    }
    expect(missing).toEqual([]);
  });

  it('never leaks a camelCase type id into player-facing text', () => {
    for (const slot of Object.keys(OUTFIT_VOCAB) as OutfitSlot[]) {
      for (const def of OUTFIT_VOCAB[slot]) {
        const line = describeSlot(patron({ [slot]: { type: def.type, colour: 'black' } }), slot);
        expect(line).not.toMatch(/[a-z][A-Z]/);
        expect(line.length).toBeGreaterThan(0);
      }
    }
  });

  it('prefixes a colour without producing "navy a blazer"', () => {
    // The noun tables must hold BARE nouns; an article baked into one shows up
    // here immediately.
    for (const [slot, type] of [['outer', 'blazer'], ['top', 'singlet'], ['accessory', 'bucketHat'], ['legs', 'skirt']] as const) {
      const line = describeSlot(patron({ [slot]: { type, colour: 'navy' } }), slot);
      expect(line).not.toMatch(/\b(a|an) /);
      expect(line.startsWith('navy ')).toBe(true);
    }
  });

  it('never leaks a camelCase type id into player-facing text', () => {
    for (const slot of slots) {
      for (const def of OUTFIT_VOCAB[slot]) {
        if (def.type === def.type.toLowerCase()) continue;
        const colour = def.colours[0] ?? 'black';
        const line = describeSlot(patron({ [slot]: { type: def.type, colour } }), slot);
        expect(line).not.toContain(def.type);
      }
    }
  });
});

describe('describeSlot', () => {
  it('names the colour of clothing but not of hair', () => {
    expect(describeSlot(patron({ top: { type: 'polo', colour: 'purple' } }), 'top')).toContain('purple');
    expect(describeSlot(patron({ shoes: { type: 'heel', colour: 'pink' } }), 'shoes')).toContain('pink');
    const hair = describeSlot(patron({ hair: { type: 'mullet', colour: 'brown' } }), 'hair');
    expect(hair).not.toContain('brown');
    expect(hair).toBe('a mullet');
  });

  it('uses absence phrasing when a slot is empty', () => {
    expect(describeSlot(patron({ outer: { type: 'none' } }), 'outer')).toBe('no jacket');
    expect(describeSlot(patron({ accessory: { type: 'none' } }), 'accessory')).toBe('no accessories');
  });

  it('calls out vintage as old, and pristine white sneakers as new', () => {
    const old = describeSlot(patron({ top: { type: 'tee', colour: 'black', vintage: true } }), 'top');
    expect(old).toMatch(/old/);
    const fresh = describeSlot(patron({ shoes: { type: 'sneaker', colour: 'white' } }), 'shoes');
    expect(fresh).toMatch(/new/);
    // Only white sneakers read as new; the same shoe in another colour must not.
    const black = describeSlot(patron({ shoes: { type: 'sneaker', colour: 'black' } }), 'shoes');
    expect(black).not.toMatch(/new/);
  });

  it('mentions a logo when the layer has one', () => {
    const line = describeSlot(patron({ top: { type: 'jersey', colour: 'red', logo: true } }), 'top');
    expect(line).toContain('logo');
    expect(describeSlot(patron({ top: { type: 'jersey', colour: 'red' } }), 'top')).not.toContain('logo');
  });
});

describe('drunk tells', () => {
  const byStage: Record<DrunkStage, number> = {
    sober: 0.05,
    tipsy: 0.3,
    loose: 0.5,
    messy: 0.75,
    maggot: 0.95,
  };
  const stages = Object.keys(byStage) as DrunkStage[];

  it('the chosen intoxication values really do span all five stages', () => {
    for (const stage of stages) expect(drunkStage(byStage[stage])).toBe(stage);
  });

  it('describeEyes and describeStance give a distinct non-empty line per stage', () => {
    for (const fn of [describeEyes, describeStance]) {
      const lines = stages.map((s) => fn(patron({}, byStage[s])));
      for (const line of lines) expect(line.length).toBeGreaterThan(0);
      expect(new Set(lines).size).toBe(5);
    }
  });

  it('eyes and stance are different observations, not the same line twice', () => {
    for (const stage of stages) {
      const p = patron({}, byStage[stage]);
      expect(describeEyes(p)).not.toBe(describeStance(p));
    }
  });
});

describe('describeZone', () => {
  it('surfaces every outfit slot across the five zones', () => {
    const p = patron({
      shoes: { type: 'thong', colour: 'orange' },
      legs: { type: 'cargo', colour: 'denim' },
      top: { type: 'crop', colour: 'purple' },
      outer: { type: 'puffer', colour: 'cream' },
      accessory: { type: 'bumbag', colour: 'yellow' },
      hair: { type: 'shaved', colour: 'pink' },
    });
    const all = ZONE_ORDER.flatMap((z) => describeZone(p, z)).join(' | ');
    for (const colour of ['orange', 'denim', 'purple', 'cream', 'yellow']) {
      expect(all).toContain(colour);
    }
    // Hair deliberately carries no colour, so it is checked by its garment word.
    expect(all).toContain('shaved head');
  });

  it('every zone returns at least one line', () => {
    const p = patron();
    for (const zone of ZONE_ORDER) {
      const lines = describeZone(p, zone);
      expect(lines.length).toBeGreaterThan(0);
      for (const line of lines) expect(line.trim().length).toBeGreaterThan(0);
    }
  });
});

describe('ZONE_BANDS', () => {
  it('tiles the 32x48 queue doll with no gaps or overlaps', () => {
    const first = ZONE_ORDER[0];
    const last = ZONE_ORDER[ZONE_ORDER.length - 1];
    expect(first).toBeDefined();
    expect(last).toBeDefined();
    expect(ZONE_BANDS[first!].y0).toBe(0);
    expect(ZONE_BANDS[last!].y1).toBe(48);
    for (let i = 0; i < ZONE_ORDER.length - 1; i++) {
      const here = ZONE_ORDER[i];
      const next = ZONE_ORDER[i + 1];
      expect(here).toBeDefined();
      expect(next).toBeDefined();
      expect(ZONE_BANDS[here!].y1).toBe(ZONE_BANDS[next!].y0);
      expect(ZONE_BANDS[here!].y1).toBeGreaterThan(ZONE_BANDS[here!].y0);
    }
  });

  it('has a band for every zone in ZONE_ORDER and no strays', () => {
    expect(Object.keys(ZONE_BANDS).sort()).toEqual([...ZONE_ORDER].sort());
  });
});

describe('greetingIndex', () => {
  it('is deterministic and inside the pool', () => {
    const p = patron();
    for (const size of [1, 2, 3, 7, 13]) {
      const i = greetingIndex(p, size);
      expect(i).toBe(greetingIndex(p, size));
      expect(i).toBeGreaterThanOrEqual(0);
      expect(i).toBeLessThan(size);
      expect(Number.isInteger(i)).toBe(true);
    }
  });

  it('returns 0 rather than NaN for an empty pool', () => {
    expect(greetingIndex(patron(), 0)).toBe(0);
  });

  it('spreads different patrons across the pool', () => {
    _resetPatronSerial();
    const ctx: GeneratorContext = { rng: new SeededRNG(5), nightDate: NIGHT };
    const seen = new Set<number>();
    for (let i = 0; i < 50; i++) seen.add(greetingIndex(generatePatron(ctx, 0), 5));
    expect(seen.size).toBeGreaterThan(1);
  });
});

describe('total over generated patrons', () => {
  it('describes 200 random patrons without an empty or undefined line', () => {
    _resetPatronSerial();
    const ctx: GeneratorContext = { rng: new SeededRNG(3), nightDate: NIGHT };
    const zones: readonly InspectZone[] = ZONE_ORDER;
    for (let i = 0; i < 200; i++) {
      const p = generatePatron(ctx, i);
      for (const zone of zones) {
        for (const line of describeZone(p, zone)) {
          expect(line.trim().length).toBeGreaterThan(0);
          expect(line).not.toContain('undefined');
        }
      }
    }
  });
});
