import { describe, expect, it } from 'vitest';
import '../src/rules/doorTypes';
import { SeededRNG } from '../src/core/SeededRNG';
import { DAZZA_TEXTS } from '../src/data/strings/dazza';
import { generatePatron, _resetPatronSerial } from '../src/patrons/generator';
import { announcedRules, ACTIVE_RULE_CAP,
  DRESS_CODE_RULES,
  activeRules,
  ruleById,
  violations,
} from '../src/rules/dressCode';
import type { OutfitLayer, OutfitSlot, Patron, PatronFlags } from '../src/data/types';

// Explicit Z: a bare ISO datetime is parsed in local time, so this would other-
// wise be one more machine-dependent input in a suite that must be reproducible.
const NIGHT = new Date('2026-07-18T00:00:00Z');
const LATE = 360; // 3 AM — every rule live

// A deliberately unremarkable outfit: passes all eight rules, so any test only
// has to state the one thing it is testing.
const CLEAN: Record<OutfitSlot, OutfitLayer> = {
  shoes: { slot: 'shoes', type: 'boot', colour: 'black' },
  legs: { slot: 'legs', type: 'jeans', colour: 'denim' },
  top: { slot: 'top', type: 'shirt', colour: 'white' },
  outer: { slot: 'outer', type: 'none', colour: 'black' },
  hair: { slot: 'hair', type: 'short', colour: 'brown' },
  accessory: { slot: 'accessory', type: 'none', colour: 'black' },
};

type OutfitOverrides = Partial<Record<OutfitSlot, Partial<Omit<OutfitLayer, 'slot'>>>>;

const patron = (outfit: OutfitOverrides = {}, flags: PatronFlags = {}): Patron => ({
  id: 'test',
  dollSeed: 1,
  archetype: 'punter',
  age: 25,
  idCard: { name: 'Test Patron', dob: '2001-01-01', expiry: '2030-01-01', photoSeed: 1 },
  outfit: (Object.keys(CLEAN) as OutfitSlot[]).map((slot) => ({ ...CLEAN[slot], ...outfit[slot] })),
  intoxication: 0.1,
  tolerance: 0.5,
  flags,
});

const ids = (rules: { id: string }[]): string[] => rules.map((r) => r.id);
const trips = (id: string, outfit: OutfitOverrides, flags?: PatronFlags): boolean =>
  {
    const rule = DRESS_CODE_RULES.find((r) => r.id === id)!;
    return ids(violations(patron(outfit, flags), rule.activeFrom)).includes(id);
  }

describe('DRESS_CODE_RULES vs Dazza texts', () => {
  it('every rule takes its text and activeFrom verbatim from the Dazza line', () => {
    for (const rule of DRESS_CODE_RULES) {
      const line = DAZZA_TEXTS.find((l) => l.ruleId === rule.id);
      expect(line, `no Dazza line for ${rule.id}`).toBeDefined();
      expect(rule.text).toBe(line!.text);
      expect(rule.activeFrom).toBe(line!.fromMin);
    }
  });

  it('every rule-bearing Dazza line has exactly one rule enforcing it', () => {
    const announced = DAZZA_TEXTS.filter((l) => l.ruleId !== undefined).map((l) => l.ruleId!);
    expect([...announced].sort()).toEqual([...ids([...DRESS_CODE_RULES])].sort());
    expect(new Set(ids([...DRESS_CODE_RULES])).size).toBe(DRESS_CODE_RULES.length);
  });

  it('carries a short card label for every rule', () => {
    for (const rule of DRESS_CODE_RULES) expect(rule.short.length).toBeGreaterThan(0);
  });

  it('ruleById finds rules by id and returns undefined otherwise', () => {
    expect(ruleById('noBlazers')?.short).toBe('NO BLAZERS');
    expect(ruleById('noCrocs')).toBeUndefined();
  });
});

describe('activeRules', () => {
  it('activates each rule exactly at activeFrom, not a minute before', () => {
    for (const rule of DRESS_CODE_RULES) {
      expect(ids(activeRules(rule.activeFrom - 1))).not.toContain(rule.id);
      expect(ids(activeRules(rule.activeFrom))).toContain(rule.id);
    }
  });

  it('returns rules in announcement order, capped at Dazza\'s attention span', () => {
    const late = activeRules(LATE);
    expect(late).toHaveLength(ACTIVE_RULE_CAP);
    expect(late.map((r) => r.activeFrom)).toEqual([...late.map((r) => r.activeFrom)].sort((a, b) => a - b));
    expect(announcedRules(LATE)).toHaveLength(DRESS_CODE_RULES.length);
    expect(activeRules(0)).toHaveLength(0);
    expect(ids(activeRules(100))).toEqual(['noThongsSinglets', 'noLogos']);
  });

  it('pins the escalation: all eight announce in order, only the last four are LIVE', () => {
    // SPECS is declared in DAZZA_TEXTS order, which is not chronological — this
    // fails if the activeFrom sort is ever dropped.
    expect(ids(announcedRules(LATE))).toEqual([
      'noThongsSinglets',
      'noLogos',
      'noSongRequesters',
      'noSunnies',
      'noHandHolders',
      'noWhiteSneakers',
      'noBucketHats',
      'noBlazers',
    ]);
    // Dazza's attention span: by 3 AM the early classics are rescinded.
    expect(ids(activeRules(LATE))).toEqual([
      'noHandHolders',
      'noWhiteSneakers',
      'noBucketHats',
      'noBlazers',
    ]);
  });
});

describe('predicates', () => {
  it('noThongsSinglets catches thongs and singlets, not boots and shirts', () => {
    expect(trips('noThongsSinglets', { shoes: { type: 'thong', colour: 'blue' } })).toBe(true);
    expect(trips('noThongsSinglets', { top: { type: 'singlet', colour: 'white' } })).toBe(true);
    expect(trips('noThongsSinglets', {})).toBe(false);
  });

  // Dazza's text says "no VISIBLE logos", and dollPlan.ts paints a logo rect for
  // the top slot only. Convicting on the other four would deny a quarter of the
  // queue over pixels that were never drawn (design §4.1). When the art pass adds
  // per-slot logos, widen LOGO_VISIBLE_SLOTS and flip these expectations.
  it('noLogos trips on a logo the renderer actually draws, and only that', () => {
    expect(trips('noLogos', { top: { type: 'tee', colour: 'black', logo: true } })).toBe(true);
    for (const slot of ['shoes', 'legs', 'outer', 'accessory'] as const) {
      const type = slot === 'outer' ? 'bomber' : slot === 'accessory' ? 'cap' : CLEAN[slot].type;
      expect(trips('noLogos', { [slot]: { type, logo: true } }), slot).toBe(false);
    }
    expect(trips('noLogos', { top: { type: 'tee', colour: 'black', logo: false } })).toBe(false);
    expect(trips('noLogos', {})).toBe(false);
  });

  it('noSunnies catches sunnies at 3 AM but ignores a bare face', () => {
    expect(trips('noSunnies', { accessory: { type: 'sunnies', colour: 'black' } })).toBe(true);
    expect(trips('noSunnies', { accessory: { type: 'chain', colour: 'yellow' } })).toBe(false);
  });

  it('noWhiteSneakers spares vintage whites and non-white sneakers', () => {
    expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'white' } })).toBe(true);
    expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'white', vintage: true } })).toBe(false);
    expect(trips('noWhiteSneakers', { shoes: { type: 'sneaker', colour: 'black' } })).toBe(false);
    expect(trips('noWhiteSneakers', { shoes: { type: 'heel', colour: 'white' } })).toBe(false);
  });

  it('noBucketHats and noBlazers key off their own slots only', () => {
    expect(trips('noBucketHats', { accessory: { type: 'bucketHat', colour: 'cream' } })).toBe(true);
    expect(trips('noBucketHats', { accessory: { type: 'cap', colour: 'black' } })).toBe(false);
    expect(trips('noBlazers', { outer: { type: 'blazer', colour: 'navy' } })).toBe(true);
    expect(trips('noBlazers', { outer: { type: 'bomber', colour: 'green' } })).toBe(false);
  });

  it('noSongRequesters needs two tells — one is just a bloke', () => {
    expect(trips('noSongRequesters', { top: { type: 'jersey', colour: 'red' } })).toBe(false);
    expect(trips('noSongRequesters', { hair: { type: 'mullet', colour: 'yellow' } })).toBe(false);
    expect(trips('noSongRequesters', {
      top: { type: 'jersey', colour: 'red' },
      hair: { type: 'mullet', colour: 'yellow' },
    })).toBe(true);
    expect(trips('noSongRequesters', {
      legs: { type: 'trackies', colour: 'grey' },
      accessory: { type: 'bumbag', colour: 'black' },
    })).toBe(true);
    expect(trips('noSongRequesters', {
      hair: { type: 'mullet', colour: 'brown' },
      accessory: { type: 'cap', colour: 'navy' },
    })).toBe(true);
  });

  it('noSongRequesters acquits when no tell is actually on screen', () => {
    // dollPlan draws the torso and legs as plain coloured rects: a jersey looks
    // like a tee and trackies look like jeans. Two invisible tells must not deny
    // anyone, or the player is stamping people over data they never saw (§4.1).
    expect(trips('noSongRequesters', {
      top: { type: 'jersey', colour: 'blue' },
      legs: { type: 'trackies', colour: 'grey' },
    })).toBe(false);
    // ...but add one tell the renderer DOES draw and the case is visible again.
    expect(trips('noSongRequesters', {
      top: { type: 'jersey', colour: 'blue' },
      legs: { type: 'trackies', colour: 'grey' },
      hair: { type: 'mullet', colour: 'brown' },
    })).toBe(true);
  });

  it('noHandHolders reads the QueueManager flag, and only that', () => {
    expect(trips('noHandHolders', {}, { handHolding: true })).toBe(true);
    expect(trips('noHandHolders', {}, {})).toBe(false);
  });

  it('predicates are total: an empty outfit throws nothing and violates nothing visual', () => {
    const naked: Patron = { ...patron(), outfit: [] };
    expect(() => violations(naked, LATE)).not.toThrow();
    expect(violations(naked, LATE)).toEqual([]);
  });

  it("'none' in a slot counts as absent, logo flag and all", () => {
    expect(violations(patron({ outer: { type: 'none' }, accessory: { type: 'none' } }), LATE)).toEqual([]);
    // An empty slot is not a garment, so a stray logo flag on one denies nobody.
    expect(trips('noLogos', { outer: { type: 'none', logo: true } })).toBe(false);
    expect(trips('noLogos', { accessory: { type: 'none', logo: true } })).toBe(false);
    // ...and logo:false on a real garment is clean, not merely "logo is present".
    expect(trips('noLogos', { top: { type: 'tee', colour: 'red', logo: false } })).toBe(false);
  });
});

describe('violations', () => {
  it('returns nothing for a clean patron with every rule live', () => {
    expect(violations(patron(), LATE)).toEqual([]);
  });

  it('reports only the rules already announced, in announcement order', () => {
    const p = patron({
      shoes: { type: 'thong', colour: 'blue' },
      outer: { type: 'blazer', colour: 'navy' },
    });
    expect(ids(violations(p, 100))).toEqual(['noThongsSinglets']);
    // By 3 AM the thongs rule has been rescinded off the card — only the
    // blazer still convicts. Dazza moved on; the door moves with him.
    expect(ids(violations(p, LATE))).toEqual(['noBlazers']);
  });

  it('survives 300 generated patrons and denies a playable fraction of them', () => {
    _resetPatronSerial();
    const ctx = { rng: new SeededRNG(5), nightDate: NIGHT };
    let denied = 0;
    for (let i = 0; i < 300; i++) {
      const p = generatePatron(ctx, LATE);
      expect(() => violations(p, LATE)).not.toThrow();
      if (violations(p, LATE).length > 0) denied++;
    }
    // Toothless or unplayable are both failures — the door has to be a judgement call.
    expect(denied / 300).toBeGreaterThan(0.1);
    expect(denied / 300).toBeLessThan(0.95);
  });
});
