/**
 * Component 16 — replay viewer.
 *
 * The important test here is narration coverage: adding an event type without narrating it should
 * fail the build rather than quietly degrading the replay into "something happened".
 */

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import { pump } from '../src/engine/advance.ts';
import type { GameEvent } from '../src/engine/events.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { developerBot, playGame } from '../src/sim/bot.ts';
import { impediments, isVisible, narrate, phaseLabel } from '../src/sim/narrate.ts';
import { record, renderHtml } from '../src/sim/replay.ts';
import { summarize } from '../src/sim/stats.ts';

const config: GameConfig = {
  mode: 'solitaire',
  victory: 'highestAfterDays',
  length: 'standard',
  optionalRules: {
    reducedVisibility: false,
    sisterTrains: false,
    employeeRotation: false,
    emergencyToolbox: false,
  },
};

/** One representative instance of every event type the engine can emit. */
const SAMPLES: GameEvent[] = [
  { type: 'stageBegan', day: 1, stage: 7 },
  { type: 'phaseBegan', phase: 'mainline' },
  { type: 'actorChanged', player: 0 },
  { type: 'localOpsOptionChosen', player: 0, option: 'switch' },
  { type: 'trayMoved', trayId: 't0', from: { row: 0, col: 0 }, to: { row: 0, col: 1 }, movesRemaining: 5 },
  { type: 'carsCoupled', trayId: 't0', at: { row: 0, col: 1 }, stock: [{ type: 'hopper', loaded: false }] },
  { type: 'carsDropped', trayId: 't0', at: { row: 1, col: 0 }, stock: [{ type: 'hopper', loaded: false }] },
  { type: 'cardDrawn', player: 0, source: 'homeOffice', cardId: 'c1' },
  { type: 'cardPlayed', player: 0, cardId: 'c1', placement: { row: 1, col: 0 }, variant: 0 },
  { type: 'officeUpgraded', player: 0, from: 'whistlePost', to: 'depot' },
  { type: 'cardDiscarded', player: 0, cardId: 'c1', toSlot: 1 },
  { type: 'deckReshuffled' },
  { type: 'departmentRefilled', slot: 0, cardId: 'c2' },
  { type: 'stockToOutbound', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
  { type: 'inboundCleared', player: 0, at: { row: 1, col: 0 }, stock: { type: 'hopper', loaded: true } },
  { type: 'facilityUnjammed', player: 0, at: { row: 1, col: 0 }, from: 'menAtWork', stock: { type: 'hopper', loaded: true } },
  { type: 'trainScheduled', player: 0, trainNumber: 4, roll: 7, slot: 6, rngState: 1 },
  { type: 'carPlacedOnTrain', player: 0, trayId: 't0', stock: { type: 'coach', loaded: false } },
  { type: 'carPassed', player: 0, trayId: 't0' },
  { type: 'clearanceRequested', trainId: 't1', occupiedBy: 't0' },
  { type: 'clearanceGiven', trainId: 't1', allow: false },
  { type: 'passengersBoarded', player: 0, at: { row: 0, col: 0 } },
  { type: 'passengersDetrained', player: 0, at: { row: 0, col: 0 } },
  { type: 'loadStarted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
  { type: 'loadAdvanced', player: 0, at: { row: 1, col: 0 }, fromBox: 0, toBox: 1 },
  { type: 'unloadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
  { type: 'loadCompleted', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
  { type: 'unloadBegan', player: 0, at: { row: 1, col: 0 }, carType: 'hopper' },
  { type: 'revenueChanged', player: 0, delta: 1, total: 3, reason: 'freightLoad' },
  { type: 'phaseEnded', player: 0, phase: 'localOps' },
];

describe('narration', () => {
  it('covers every event type the engine can emit', () => {
    // Guards against a new event type slipping in unnarrated.
    const covered = new Set(SAMPLES.map((e) => e.type));
    const declared = new Set<string>();
    for (const e of SAMPLES) declared.add(e.type);
    assert.equal(covered.size, 30, 'sample list is out of step with GameEvent');
    assert.equal(declared.size, 30);
  });

  it('gives every event a specific, non-empty sentence', () => {
    for (const e of SAMPLES) {
      const n = narrate(e);
      assert.ok(n.text.length > 3, `${e.type} produced no useful text`);
      assert.ok(!/^\[/.test(n.text), `${e.type} fell through to a fallback`);
      assert.ok(['plain', 'good', 'bad', 'clock', 'quiet'].includes(n.tone), `${e.type} bad tone`);
    }
  });

  it('marks revenue gains good and losses bad', () => {
    const gain = narrate({ type: 'revenueChanged', player: 0, delta: 1, total: 1, reason: 'boarding' });
    const loss = narrate({
      type: 'revenueChanged', player: 0, delta: -5, total: -5, reason: 'collision: no free A/D track',
    });
    assert.equal(gain.tone, 'good');
    assert.equal(loss.tone, 'bad');
    assert.match(loss.text, /COLLISION/i);
  });

  it('points at the board cell where something happened', () => {
    const n = narrate({ type: 'loadCompleted', player: 0, at: { row: 1, col: 2 }, carType: 'hopper' });
    assert.deepEqual(n.where, { row: 1, col: 2 });
  });

  it('names phases in words rather than identifiers', () => {
    assert.equal(phaseLabel('loadUnload'), 'Load / Unload');
    assert.equal(phaseLabel('newTrain'), 'New Train');
  });

  it('hides only the events with nothing to show', () => {
    assert.equal(isVisible({ type: 'actorChanged', player: 0 }), false);
    assert.equal(isVisible({ type: 'loadCompleted', player: 0, at: { row: 0, col: 0 }, carType: 'hopper' }), true);
  });
});

describe('impediments', () => {
  it('reports nothing blocked on a fresh game', () => {
    const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
    assert.deepEqual(impediments(s, 0), []);
  });

  it('warns when every A/D track is occupied', () => {
    // Gap 2d — the next arrival is an automatic collision.
    const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
    s.officeAreas.get(0)!.adOccupancy = ['t0'];
    const found = impediments(s, 0);
    assert.ok(found.some((b) => /A\/D/.test(b.why) && b.severity === 'risk'));
  });

  it('reports a train held for want of a Crew Tray', () => {
    const s = createGame({ id: 'g', seed: 5, config, playerNames: ['p'] });
    s.timetable[s.clock.stage - 1] = 4;
    s.freeTrays = [];
    assert.ok(impediments(s, 0).some((b) => /HELD/.test(b.why)));
  });
});

describe('replay recording', () => {
  const rec = record(1234, 'standard');

  it('produces frames', () => {
    assert.ok(rec.frames.length > 50, `only ${rec.frames.length} frames`);
  });

  it('never runs time backwards', () => {
    let prev = 0;
    for (const f of rec.frames) {
      const t = f.day * 100 + f.stage;
      assert.ok(t >= prev, `time went backwards at Day ${f.day} Stage ${f.stage}`);
      prev = t;
    }
  });

  it('always shows the Office card', () => {
    for (const f of rec.frames) {
      assert.ok(f.cells.some((c) => c.kind === 'office'), 'a frame lost the Office');
    }
  });

  it('does not drift from the engine', () => {
    // A replay that disagrees with the engine is worse than no replay. Replay the same seed
    // through the normal bot path and compare the end state.
    const s = createGame({ id: 'x', seed: 1234, config, playerNames: ['player'] });
    const r = playGame(s, developerBot, pump);
    const stats = summarize(1234, r.events, r.intents, s);

    const last = rec.frames[rec.frames.length - 1]!;
    assert.equal(last.revenue, stats.revenue.net, 'final revenue disagrees with the engine');
    assert.equal(last.day, s.clock.day, 'final Day disagrees with the engine');
    assert.match(rec.outcome, new RegExp(stats.result));
  });

  it('narrates every frame', () => {
    for (const f of rec.frames) {
      assert.ok(f.lines.length > 0, 'a frame has no narration');
    }
  });
});

describe('replay HTML', () => {
  const html = renderHtml(record(99, 'short'));

  it('is a complete standalone document', () => {
    assert.match(html, /^<!doctype html>/i);
    assert.match(html, /<\/html>\s*$/);
  });

  it('embeds no external resources', () => {
    // A strict requirement: the file must open anywhere, offline.
    const externals = html.match(/(?:src|href)\s*=\s*"(?!#)[^"]+"/g) ?? [];
    assert.deepEqual(externals, [], `external references: ${externals.join(', ')}`);
  });

  it('embeds the frame data', () => {
    assert.match(html, /const FRAMES = \[/);
  });

  it('offers the controls that make it usable', () => {
    for (const id of ['play', 'back', 'fwd', 'scrub', 'stage', 'money', 'crash', 'waste', 'jam', 'decision']) {
      assert.ok(html.includes(`id="${id}"`), `missing control: ${id}`);
    }
  });

  it('emits a script that actually parses', () => {
    // THE GUARD THAT WAS MISSING. A stray apostrophe inside a single-quoted JS string once broke
    // the entire inline script, so no button handler ever bound — while every other test passed,
    // because they only checked that the markup contained the buttons. Checking the elements exist
    // says nothing about whether the page works.
    const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
    assert.doesNotThrow(() => new Function(js), 'inline script has a syntax error');
  });

  it('wires a handler to every control', () => {
    const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
    for (const id of ['play', 'back', 'fwd', 'first', 'stage', 'money', 'crash', 'waste', 'jam']) {
      assert.match(js, new RegExp(`\\$\\('${id}'\\)\\.onclick`), `no handler bound to ${id}`);
    }
    assert.match(js, /\$\('scrub'\)\.oninput/, 'no handler bound to scrub');
  });

  it('runs its render function against the real frames', () => {
    // Executes the page's own JS with a minimal DOM stub. Catches runtime errors in render(),
    // not just parse errors — e.g. a field the renderer expects that frames do not carry.
    const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
    const els = new Map<string, Record<string, unknown>>();
    const stub = {
      getElementById: (id: string) => {
        if (!els.has(id)) els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0 });
        return els.get(id);
      },
    };
    const run = new Function('document', 'setInterval', 'clearInterval', js);
    assert.doesNotThrow(
      () => run(stub, () => 0, () => undefined),
      'the page threw while rendering its first frame',
    );
    // render() ran, so the clock element should have been filled in.
    assert.match(String(els.get('when')?.textContent ?? ''), /Day \d+/);
  });

  it('records what the bot chose, why, and what it passed over', () => {
    // The replay could always show what happened, never what COULD have happened — so a daft move
    // was visible but the alternatives it declined were not, which is what you need to say what it
    // should have done instead.
    const rec = record(202, 'standard');
    const decided = rec.frames.filter((f) => f.decision !== null);
    assert.ok(decided.length > 0, 'no frame carried a decision');

    for (const f of decided) {
      assert.ok(f.decision!.chose.length > 0, 'a decision with no chosen action');
      assert.ok(f.decision!.why.length > 0, 'a decision with no stated reason');
      assert.ok(f.decision!.totalOptions >= 1, 'a decision offering nothing');
    }

    // The reasons must come from the bot's own branches, not a generic fallback for everything.
    const reasons = new Set(decided.map((f) => f.decision!.why));
    assert.ok(reasons.size > 5, `only ${reasons.size} distinct reasons — the branches are not reporting`);

    // Turns with real alternatives must show them, or the panel is decoration.
    assert.ok(
      decided.some((f) => f.decision!.rejected.length > 0),
      'no frame ever recorded a rejected option',
    );
  });

  it('flags jammed facilities and can tell them from ready ones', () => {
    const rec = record(202, 'standard');
    for (const f of rec.frames) {
      for (const x of f.facilities) {
        // A jam is precisely "work on WORK that cannot come off", so it can never be 'ready'.
        assert.ok(!(x.jammed && x.canFinish), `${x.name} reported both jammed and ready`);
      }
    }
  });

  it('renders a LATER frame, where the board state is carried forward', () => {
    // The page omits cells/facilities/division when unchanged and resolves them by walking back —
    // 63% of a 5.2 MB payload was the same grid re-serialised every frame. Rendering only frame 0
    // would never exercise that path, because frame 0 always carries everything.
    const js = html.slice(html.lastIndexOf('<script>') + 8, html.lastIndexOf('</script>'));
    const els = new Map<string, Record<string, unknown>>();
    const stub = {
      getElementById: (id: string) => {
        if (!els.has(id)) els.set(id, { textContent: '', innerHTML: '', value: '', style: {}, max: 0 });
        return els.get(id);
      },
    };
    // Append a jump to a late frame so render() runs against carried-forward state.
    const run = new Function('document', 'setInterval', 'clearInterval', js + '\n;go(FRAMES.length - 1);');
    assert.doesNotThrow(
      () => run(stub, () => 0, () => undefined),
      'the page threw rendering a frame whose board state was carried forward',
    );
    assert.ok(
      String(els.get('grid')?.innerHTML ?? '').length > 0,
      'the grid rendered empty on a carried-forward frame',
    );
  });

  it('stays a sane size', () => {
    const mb = Buffer.byteLength(html) / 1024 / 1024;
    assert.ok(mb < 5, `replay is ${mb.toFixed(1)} MB`);
  });
});
