import { beforeEach, describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import { idDisplayLines, idVerdict, type IdTell } from '../src/rules/idCheck';
import { generatePatron, _resetPatronSerial, type GeneratorContext } from '../src/patrons/generator';
import type { IdCard, Patron } from '../src/data/types';

const NIGHT = new Date('2026-07-18T00:00:00');

const card = (over: Partial<IdCard> = {}): IdCard => ({
  name: 'Shazza Nguyen',
  dob: '2000-06-01',
  expiry: '2030-06-01',
  photoSeed: 1,
  ...over,
});

const on = (iso: string): Date => new Date(`${iso}T00:00:00`);

describe('idVerdict — birthday boundary', () => {
  it('18th birthday is tonight: 18, not underage, flagged', () => {
    const v = idVerdict(card({ dob: '2008-07-18' }), NIGHT);
    expect(v.claimedAge).toBe(18);
    expect(v.underage).toBe(false);
    expect(v.turnsEighteenTonight).toBe(true);
  });

  it('18th birthday is tomorrow: still 17, underage', () => {
    const v = idVerdict(card({ dob: '2008-07-19' }), NIGHT);
    expect(v.claimedAge).toBe(17);
    expect(v.underage).toBe(true);
    expect(v.turnsEighteenTonight).toBe(false);
  });

  it('18th birthday was yesterday: 18, no longer the birthday', () => {
    const v = idVerdict(card({ dob: '2008-07-17' }), NIGHT);
    expect(v.claimedAge).toBe(18);
    expect(v.turnsEighteenTonight).toBe(false);
  });

  it('turnsEighteenTonight only ever fires at exactly 18', () => {
    expect(idVerdict(card({ dob: '2007-07-18' }), NIGHT).turnsEighteenTonight).toBe(false);
    expect(idVerdict(card({ dob: '2009-07-18' }), NIGHT).turnsEighteenTonight).toBe(false);
  });
});

describe('idVerdict — leap day DOB', () => {
  it('29 Feb DOB is still 17 on 28 Feb of a non-leap year', () => {
    const v = idVerdict(card({ dob: '2008-02-29' }), on('2026-02-28'));
    expect(v.claimedAge).toBe(17);
    expect(v.underage).toBe(true);
  });

  it('29 Feb DOB turns 18 on 1 March of a non-leap year', () => {
    const v = idVerdict(card({ dob: '2008-02-29' }), on('2026-03-01'));
    expect(v.claimedAge).toBe(18);
    expect(v.underage).toBe(false);
    // Deliberate: leap babies get the flag on the day the age math grants it,
    // rather than never getting it in a non-leap year.
    expect(v.turnsEighteenTonight).toBe(true);
  });

  // NB: a leap baby's 18th birthday can never fall on a real 29 Feb (leap years
  // are 4 apart, 18 is not a multiple of 4), so the interesting leap-night case
  // is a 28 Feb DOB the day AFTER its birthday.
  it('28 Feb DOB on a 29 Feb night: birthday was yesterday', () => {
    const v = idVerdict(card({ dob: '2010-02-28' }), on('2028-02-29'));
    expect(v.claimedAge).toBe(18);
    expect(v.turnsEighteenTonight).toBe(false);
  });

  it('29 Feb DOB evaluated on a real 29 Feb is exactly on its birthday', () => {
    const v = idVerdict(card({ dob: '2008-02-29' }), on('2028-02-29'));
    expect(v.claimedAge).toBe(20);
    expect(v.underage).toBe(false);
  });
});

describe('idVerdict — expiry boundary', () => {
  it('expiry yesterday is expired', () => {
    const v = idVerdict(card({ expiry: '2026-07-17' }), NIGHT);
    expect(v.expired).toBe(true);
    expect(v.expiresTonight).toBe(false);
    expect(v.tells).toEqual(['expired']);
  });

  it('expiry tonight is valid — the trap', () => {
    const v = idVerdict(card({ expiry: '2026-07-18' }), NIGHT);
    expect(v.expired).toBe(false);
    expect(v.expiresTonight).toBe(true);
    expect(v.tells).toEqual([]);
    expect(v.looksFake).toBe(false);
  });

  it('expiry tomorrow is valid and unremarkable', () => {
    const v = idVerdict(card({ expiry: '2026-07-19' }), NIGHT);
    expect(v.expired).toBe(false);
    expect(v.expiresTonight).toBe(false);
  });

  it('a nightDate carrying a time of day still compares day-to-day', () => {
    const v = idVerdict(card({ expiry: '2026-07-18' }), new Date('2026-07-18T02:30:00'));
    expect(v.expired).toBe(false);
    expect(v.expiresTonight).toBe(true);
  });
});

describe('idVerdict — tells', () => {
  it('a single visible tell comes back alone', () => {
    const v = idVerdict(card({ fake: { peelingLaminate: true } }), NIGHT);
    expect(v.tells).toEqual(['peelingLaminate']);
    expect(v.looksFake).toBe(true);
  });

  it('a clean card with a future expiry has no tells', () => {
    const v = idVerdict(card(), NIGHT);
    expect(v.tells).toEqual([]);
    expect(v.looksFake).toBe(false);
  });

  it('no fake object but a past expiry is still catchable', () => {
    const v = idVerdict(card({ expiry: '2024-01-01' }), NIGHT);
    expect(v.tells).toEqual(['expired']);
    expect(v.looksFake).toBe(true);
  });

  it('all tells come back in fixed reading order', () => {
    const v = idVerdict(
      card({
        expiry: '2020-05-05',
        fake: { photoMismatch: true, jokeName: true, wrongHologram: true, peelingLaminate: true },
      }),
      NIGHT,
    );
    expect(v.tells).toEqual([
      'peelingLaminate',
      'wrongHologram',
      'jokeName',
      'photoMismatch',
      'expired',
    ]);
  });

  it('subsets keep that order regardless of how the flags were written', () => {
    const v = idVerdict(card({ fake: { photoMismatch: true, wrongHologram: true } }), NIGHT);
    expect(v.tells).toEqual(['wrongHologram', 'photoMismatch']);
  });

  it('an empty fake object alone is not a tell', () => {
    const v = idVerdict(card({ fake: {} }), NIGHT);
    expect(v.tells).toEqual([]);
    expect(v.looksFake).toBe(false);
  });

  // Guards against a later "if underage, skip the tell scan" shortcut: the two
  // are independent readings of the same card and must both survive.
  it('a card that both claims underage and has expired reports both', () => {
    const v = idVerdict(card({ dob: '2009-07-19', expiry: '2025-01-01' }), NIGHT);
    expect(v.claimedAge).toBe(16);
    expect(v.underage).toBe(true);
    expect(v.expired).toBe(true);
    expect(v.tells).toEqual(['expired']);
    expect(v.looksFake).toBe(true);
  });

  it('TELL_ORDER covers every IdTell — no tell can be silently unreportable', () => {
    const everyTell: IdTell[] = [
      'peelingLaminate',
      'wrongHologram',
      'jokeName',
      'photoMismatch',
      'expired',
    ];
    const v = idVerdict(
      card({
        expiry: '2020-05-05',
        fake: { peelingLaminate: true, wrongHologram: true, jokeName: true, photoMismatch: true },
      }),
      NIGHT,
    );
    expect([...v.tells].sort()).toEqual([...everyTell].sort());
  });
});

describe('idVerdict — calendar edges', () => {
  it('DOB 31 Dec against a 1 Jan night', () => {
    expect(idVerdict(card({ dob: '2008-12-31' }), on('2027-01-01')).claimedAge).toBe(18);
    expect(idVerdict(card({ dob: '2009-01-01' }), on('2027-01-01')).turnsEighteenTonight).toBe(true);
  });

  it('DOB 31 Jan checked on 28 Feb', () => {
    const v = idVerdict(card({ dob: '2008-01-31' }), on('2026-02-28'));
    expect(v.claimedAge).toBe(18);
    expect(v.turnsEighteenTonight).toBe(false);
  });
});

describe('idDisplayLines', () => {
  it('formats DD/MM/YYYY with zero padding', () => {
    const lines = idDisplayLines(card({ dob: '2008-01-05', expiry: '2029-11-09' }), NIGHT);
    expect(lines.dob).toBe('05/01/2008');
    expect(lines.expiry).toBe('09/11/2029');
    expect(lines.name).toBe('Shazza Nguyen');
    expect(lines.age).toBe('18');
  });

  it('age line agrees with the verdict at the underage boundary', () => {
    const id = card({ dob: '2008-07-19' });
    expect(idDisplayLines(id, NIGHT).age).toBe('17');
    expect(String(idVerdict(id, NIGHT).claimedAge)).toBe('17');
  });
});

describe('integration sweep over generated patrons', () => {
  const SWEEP_NIGHT = new Date('2026-07-18T00:00:00');
  let crowd: Patron[];
  let kids: Patron[];

  beforeEach(() => {
    _resetPatronSerial();
    const ctx: GeneratorContext = { rng: new SeededRNG(11), nightDate: SWEEP_NIGHT };
    crowd = Array.from({ length: 800 }, (_, i) => generatePatron(ctx, i % 360));
    const kidCtx: GeneratorContext = { rng: new SeededRNG(11), nightDate: SWEEP_NIGHT };
    kids = Array.from({ length: 60 }, () => generatePatron(kidCtx, 0, 'almost18'));
  });

  it('every fake in the crowd is actually catchable', () => {
    const suspect = crowd.filter(
      (p) => p.idCard.fake || new Date(`${p.idCard.expiry}T00:00:00`) < SWEEP_NIGHT,
    );
    expect(suspect.length).toBeGreaterThan(0);
    for (const p of suspect) {
      expect(idVerdict(p.idCard, SWEEP_NIGHT).looksFake).toBe(true);
    }
  });

  it('clean cards in the crowd never read as fake', () => {
    const clean = crowd.filter(
      (p) => !p.idCard.fake && new Date(`${p.idCard.expiry}T00:00:00`) >= SWEEP_NIGHT,
    );
    expect(clean.length).toBeGreaterThan(0);
    for (const p of clean) {
      expect(idVerdict(p.idCard, SWEEP_NIGHT).looksFake).toBe(false);
    }
  });

  it("almost18 cards claim 18+ while the kid is 17 — tells are the only catch", () => {
    expect(kids.length).toBe(60);
    for (const kid of kids) {
      expect(kid.age).toBe(17);
      const v = idVerdict(kid.idCard, SWEEP_NIGHT);
      expect(v.claimedAge).toBeGreaterThanOrEqual(18);
      expect(v.underage).toBe(false);
      expect(v.looksFake).toBe(true);
    }
  });

  // The override run above is a forced sample; this checks the almost18s that
  // actually turn up in a naturally-weighted crowd, which is what ships.
  it('almost18s occurring naturally in the crowd are also lying and catchable', () => {
    const inCrowd = crowd.filter((p) => p.archetype === 'almost18');
    expect(inCrowd.length).toBeGreaterThan(0);
    for (const kid of inCrowd) {
      expect(kid.age).toBe(17);
      const v = idVerdict(kid.idCard, SWEEP_NIGHT);
      expect(v.claimedAge).toBeGreaterThanOrEqual(18);
      expect(v.underage).toBe(false);
      expect(v.looksFake).toBe(true);
    }
  });

  it('idVerdict never throws across the whole crowd', () => {
    expect(() => {
      for (const p of [...crowd, ...kids]) {
        idVerdict(p.idCard, SWEEP_NIGHT);
        idDisplayLines(p.idCard, SWEEP_NIGHT);
      }
    }).not.toThrow();
  });
});
