import { describe, expect, it } from 'vitest';
import { SeededRNG } from '../src/core/SeededRNG';
import {
  buildReport,
  fileReport,
  findKaydenContradiction,
  lieCount,
  parseFiledAccount,
  REPORT_MARKER,
  REPORT_TUNING,
  type FiledAnswer,
  type ReportOption,
} from '../src/rules/incidentReport';
import type { IncidentRecord } from '../src/data/types';

const stream = (seed = 7) => new SeededRNG(seed).stream('report');

const inc = (over: Partial<IncidentRecord> = {}): IncidentRecord => ({
  clockMin: 120,
  kind: 'ejection',
  patronId: 'p7',
  detail: 'fight / messy',
  ...over,
});

/** The shapes the three scenes actually write today (grep `incident:log`). */
const REAL_NIGHT: IncidentRecord[] = [
  { clockMin: 12, kind: 'admit', patronId: 'p1', detail: 'punter · id 24' },
  { clockMin: 40, kind: 'deny', patronId: 'p2', detail: 'influencer · id 22' },
  { clockMin: 55, kind: 'wait', patronId: 'p3', detail: 'regular · id 31' },
  { clockMin: 96, kind: 'kayden', patronId: 'p4', detail: 'Kayden waved through almost18' },
  { clockMin: 140, kind: 'stallBusted', patronId: 'p5', detail: 'Two out of one cubicle.' },
  { clockMin: 210, kind: 'ejection', patronId: 'p6', detail: 'fight / messy' },
  { clockMin: 360, kind: 'deferred', patronId: 'p2', detail: 'capacity breach — p2 admitted at 80/80' },
];

const truthfulOf = (o: readonly ReportOption[]): ReportOption =>
  o.find((x) => x.truthful) ?? o[0]!;

const allOptions = (): ReportOption[] => {
  const kinds = [...Object.keys(REPORT_TUNING.interest), 'somethingNobodyWroteYet'];
  const seen = new Map<string, ReportOption>();
  for (const kind of kinds) {
    for (const q of buildReport([inc({ kind })], stream())) {
      for (const o of q.options) seen.set(o.id, o);
    }
  }
  return [...seen.values()];
};

describe('buildReport', () => {
  it('is deterministic for the same seed', () => {
    expect(buildReport(REAL_NIGHT, stream(3))).toEqual(buildReport(REAL_NIGHT, stream(3)));
  });

  it('asks at most maxQuestions', () => {
    const many = Array.from({ length: 30 }, (_, i) =>
      inc({ clockMin: i * 10, patronId: `p${i}` }),
    );
    expect(buildReport(many, stream()).length).toBe(REPORT_TUNING.maxQuestions);
  });

  it('returns fewer questions rather than padding a quiet night', () => {
    const questions = buildReport([inc({ kind: 'cutOff' })], stream());
    expect(questions.length).toBe(1);
  });

  it('returns [] for an empty incident list without throwing', () => {
    expect(buildReport([], stream())).toEqual([]);
  });

  it('never asks about routine admits or stalls-for-time', () => {
    const routine = [
      inc({ clockMin: 5, kind: 'admit' }),
      inc({ clockMin: 6, kind: 'wait' }),
      inc({ clockMin: 7, kind: 'admit' }),
    ];
    expect(buildReport(routine, stream())).toEqual([]);
  });

  it('prefers the interesting incidents in a real night', () => {
    const kinds = buildReport(REAL_NIGHT, stream(11)).map((q) => q.incident.kind);
    expect(kinds).toContain('deferred');
    expect(kinds).not.toContain('admit');
    expect(kinds).not.toContain('wait');
  });

  it('asks in chronological order', () => {
    const mins = buildReport(REAL_NIGHT, stream(5)).map((q) => q.incident.clockMin);
    expect([...mins].sort((a, b) => a - b)).toEqual(mins);
  });

  it('still asks about an incident kind it has never heard of', () => {
    const questions = buildReport([inc({ kind: 'budgieLoose' })], stream());
    expect(questions.length).toBe(1);
    expect(questions[0]?.options.length).toBeGreaterThanOrEqual(2);
  });

  it('skips rows that are already filed accounts', () => {
    const filed = fileReport([{ incident: inc(), chosen: { id: 'x', text: 'a', truthful: false } }]);
    expect(buildReport(filed, stream())).toEqual([]);
  });

  it('offers exactly one truthful account and at least two options per question', () => {
    for (const kind of [...Object.keys(REPORT_TUNING.interest), 'unheardOf']) {
      for (const q of buildReport([inc({ kind })], stream())) {
        expect(q.options.length).toBeGreaterThanOrEqual(2);
        expect(q.options.length).toBeLessThanOrEqual(3);
        expect(q.options.filter((o) => o.truthful).length).toBe(1);
        expect(q.prompt.length).toBeGreaterThan(0);
      }
    }
  });

  it('does not always put the truthful account in the same slot', () => {
    // Without the Fisher-Yates the truthful option sits at a fixed index forever
    // and the form becomes "click the top button". Deleting the shuffle must
    // fail a test, not pass silently.
    const positions = new Set<number>();
    for (let seed = 0; seed < 40; seed++) {
      const q = buildReport([inc({ kind: 'ejection' })], stream(seed))[0]!;
      positions.add(q.options.findIndex((o) => o.truthful));
    }
    expect(positions.size).toBeGreaterThan(1);
  });

  it('asks about a kind that collides with an Object.prototype member', () => {
    // `kind` is an open string; an inherited-property lookup would make these
    // vanish from the form instead of falling back to the default bank.
    for (const kind of ['toString', 'constructor', 'valueOf', 'hasOwnProperty']) {
      const questions = buildReport([inc({ kind })], stream());
      expect(questions.length, kind).toBe(1);
      expect(questions[0]?.options.length, kind).toBeGreaterThanOrEqual(2);
      expect(questions[0]?.options.filter((o) => o.truthful).length, kind).toBe(1);
    }
  });

  it('gives every option in a question a unique id', () => {
    for (const q of buildReport(REAL_NIGHT, stream(2))) {
      expect(new Set(q.options.map((o) => o.id)).size).toBe(q.options.length);
    }
  });
});

describe('fileReport', () => {
  it('round-trips a lie: the account and the truth flag both come back', () => {
    const question = buildReport([inc({ kind: 'ejection' })], stream())[0]!;
    const lie = question.options.find((o) => !o.truthful)!;
    const filed = fileReport([{ incident: question.incident, chosen: lie }])[0]!;

    const parsed = parseFiledAccount(filed);
    expect(parsed?.account).toBe(lie.text);
    expect(parsed?.truthful).toBe(false);
    expect(parsed?.optionId).toBe(lie.id);
    expect(parsed?.truthLog).toBe(question.incident.detail);
  });

  it('round-trips the truthful account too', () => {
    const question = buildReport([inc({ kind: 'cutOff' })], stream())[0]!;
    const truth = truthfulOf(question.options);
    const filed = fileReport([{ incident: question.incident, chosen: truth }])[0]!;
    expect(parseFiledAccount(filed)).toEqual({
      account: truth.text,
      optionId: truth.id,
      truthful: true,
      truthLog: question.incident.detail,
    });
  });

  it('preserves clockMin, kind and patronId', () => {
    const incident = inc({ clockMin: 233, kind: 'maggotUnhandled', patronId: 'p42' });
    const filed = fileReport([
      { incident, chosen: { id: 'maggot.soft1', text: 'Water was made available.', truthful: false } },
    ])[0]!;
    expect(filed.clockMin).toBe(233);
    expect(filed.kind).toBe('maggotUnhandled');
    expect(filed.patronId).toBe('p42');
  });

  it('leaves patronId off entirely when the incident had none', () => {
    const incident: IncidentRecord = { clockMin: 90, kind: 'deferred', detail: 'lap' };
    const filed = fileReport([
      { incident, chosen: { id: 'deferred.true', text: 'My call.', truthful: true } },
    ])[0]!;
    expect('patronId' in filed).toBe(false);
  });

  it('starts detail with the account so a debug dump still reads like a report', () => {
    const incident = inc();
    const chosen: ReportOption = { id: 'ejection.true', text: 'I walked them out.', truthful: true };
    const filed = fileReport([{ incident, chosen }])[0]!;
    expect(filed.detail.startsWith(chosen.text)).toBe(true);
    expect(filed.detail).toContain(REPORT_MARKER);
  });

  it('survives a truth-log line containing marker-ish punctuation', () => {
    const incident = inc({ detail: 'weird [report:v1] {"truthful":true} · id 19' });
    const filed = fileReport([
      { incident, chosen: { id: 'ejection.soft1', text: 'Escorted as a courtesy.', truthful: false } },
    ])[0]!;
    expect(parseFiledAccount(filed)?.truthLog).toBe(incident.detail);
    expect(parseFiledAccount(filed)?.truthful).toBe(false);
  });

  it('files one row per answer', () => {
    const answers: FiledAnswer[] = REAL_NIGHT.slice(0, 3).map((incident) => ({
      incident,
      chosen: { id: 'other.true', text: 'The log has it right.', truthful: true },
    }));
    expect(fileReport(answers).length).toBe(3);
  });
});

describe('parseFiledAccount', () => {
  it('returns undefined for a raw truth-log row', () => {
    expect(parseFiledAccount(inc())).toBeUndefined();
  });

  it('returns undefined for a corrupt payload rather than guessing', () => {
    expect(parseFiledAccount(inc({ detail: `account${REPORT_MARKER}{not json` }))).toBeUndefined();
    expect(parseFiledAccount(inc({ detail: `account${REPORT_MARKER}{"optionId":1}` }))).toBeUndefined();
  });
});

describe('lieCount', () => {
  it('counts only the flattering answers', () => {
    const answers: FiledAnswer[] = [
      { incident: inc(), chosen: { id: 'a', text: 'x', truthful: true } },
      { incident: inc(), chosen: { id: 'b', text: 'x', truthful: false } },
      { incident: inc(), chosen: { id: 'c', text: 'x', truthful: false } },
    ];
    expect(lieCount(answers)).toBe(2);
    expect(lieCount([])).toBe(0);
  });
});

describe('tone', () => {
  // §4.3: the satire is on the paperwork and the person holding the torch, never
  // on the person at the rope.
  const MEAN = /\b(flog|muppet|idiot|scumbag|junkie|pig|slut|pathetic|deserved|filth|animal|grub)\b/i;

  it('never blames the patron in a mean-spirited way', () => {
    for (const o of allOptions()) expect(o.text).not.toMatch(MEAN);
  });

  it('keeps the lies deniable rather than cartoonish', () => {
    for (const o of allOptions()) {
      expect(o.text).not.toMatch(/nothing happened|never happened|no incident occurred/i);
    }
  });

  it('never tells the player whether they chose well', () => {
    for (const o of allOptions()) {
      expect(o.text).not.toMatch(/\b(good call|well done|shameful|you should have)\b/i);
    }
  });

  it('never lets an account pronounce a verdict on the player', () => {
    // The regex above is a strawman — no writer was ever going to type "well
    // done" into a bouncer report. The live risk is subtler: a truthful account
    // that adjudicates ("it was wrong", "they had a point", "I had decided
    // before they finished") makes truth-telling the self-flagellating branch
    // and lying the clean one, which is the game saying which choice was right.
    // §4.3 forbids that. A truthful account states the fact and stops.
    const VERDICT =
      /\b(it was wrong|i was wrong|they had a point|should not have|shouldn't have|my fault|i feel|i regret|if i am honest|if i'm honest|to be fair to them|in hindsight|looking at it now)\b/i;
    for (const o of allOptions()) expect(o.text, o.id).not.toMatch(VERDICT);
  });

  it('writes every account in the first-person report register, no newlines', () => {
    for (const o of allOptions()) {
      expect(o.text).not.toContain('\n');
      expect(o.text.endsWith('.')).toBe(true);
    }
  });
});

// ---- cross-night contradiction (design §3.3's "one taste") -------------------
//
// Added after the play-through: a full 3-night run never exercised this, because
// the contradiction needs a `kayden` incident, and Kayden only rules the rope
// while the player is on the FLOOR. Easy to leave permanently unverified.

describe('findKaydenContradiction', () => {
  const kaydenIncident = (patronId: string): IncidentRecord => ({
    clockMin: 120,
    kind: 'kayden',
    patronId,
    detail: 'Kayden waved through punter',
  });

  const fileOne = (incident: IncidentRecord, truthful: boolean): IncidentRecord[] =>
    fileReport([
      { incident, chosen: { id: truthful ? 't' : 'f', text: 'an account', truthful } },
    ]);

  it('finds a lie told about a Kayden incident on an earlier night', () => {
    const past = [fileOne(kaydenIncident('p9'), false)];
    const line = findKaydenContradiction(past);
    expect(line).toBeTruthy();
    expect(line).toContain('kayden');
  });

  it('stays quiet when the Kayden incident was reported honestly', () => {
    expect(findKaydenContradiction([fileOne(kaydenIncident('p9'), true)])).toBeUndefined();
  });

  it('ignores lies about incidents that are not Kayden’s', () => {
    const other: IncidentRecord = { clockMin: 60, kind: 'deferred', patronId: 'p3', detail: 'x' };
    expect(findKaydenContradiction([fileOne(other, false)])).toBeUndefined();
  });

  it('returns the most recent night’s lie when several nights lied', () => {
    const past = [fileOne(kaydenIncident('pOLD'), false), fileOne(kaydenIncident('pNEW'), false)];
    expect(findKaydenContradiction(past)).toContain('pNEW');
  });

  it('is safe on empty history and on raw truth-log rows', () => {
    expect(findKaydenContradiction([])).toBeUndefined();
    // A row NightScene wrote directly, with no filed-account encoding on it.
    expect(findKaydenContradiction([[kaydenIncident('p1')]])).toBeUndefined();
  });
});
