/**
 * Component 16 — Game replay viewer.
 *
 * Records one game and writes a **self-contained HTML file** that plays it back step by step.
 *
 *   node src/sim/replay.ts [--seed 1234] [--length standard] [--out replay.html]
 *
 * The browser never runs the engine. Frames are precomputed here in Node and embedded as JSON, so
 * the page is a dumb renderer with no bundling and no build step.
 *
 * The recorder drives `advance()` and `applyIntent()` itself rather than reusing `playGame`'s pump
 * loop, because `pump` batches a whole automatic phase into one call — which would collapse an
 * entire Mainline Phase into a single frame. Driving `advance` gives one frame per step.
 *
 * Priority is CLARITY over polish: plain boxes, words not symbols, and an explicit panel for what
 * is currently blocked.
 */

import { writeFileSync } from 'node:fs';

import { advance } from '../engine/advance.ts';
import { applyIntent, areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import type { GameLength } from '../engine/content.ts';
import { MAINLINE_PROFILES, lengthProfile, officeProfile } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { createGame } from '../engine/setup.ts';
import type { Facility, GameConfig, GameState } from '../engine/state.ts';
import { developerBot, lastChoiceReason } from './bot.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, idleNote, impediments, isVisible, narrate, phaseLabel } from './narrate.ts';

// ---------------------------------------------------------------------------
// Frame shape — only what the viewer draws
// ---------------------------------------------------------------------------

export type CellView = {
  row: number;
  col: number;
  kind: string;
  label: string;
  running: boolean;
  tray: string | null;
  cars: string[];
  facility: FacilityView | null;
};

export type FacilityView = {
  name: string;
  commodity: string;
  flow: string;
  green: string[];
  greenCap: number;
  maw: (string | null)[];
  red: string[];
  redCap: number;
  track: string[];
  trackCap: number;
  laborers: string;
  porters: string;
  /**
   * Can a load actually come off WORK onto a spotted car (§9.3)? A load with nowhere to go parks on
   * WORK and LOCKS the industry track, blocking the very car that would clear it — the deadlock
   * that held freight to a 3% completion rate.
   */
  canFinish: boolean;
  /** A load is sitting on WORK with no spotted car to receive it. */
  jammed: boolean;
};

export type TrainChip = { label: string; consist: string[] };
export type DivisionView = { kind: string; label: string; trains: TrainChip[][] };

export type Frame = {
  day: number;
  stage: number;
  clock: string;
  phase: string;
  actor: number | null;
  superintendent: number;
  revenue: number;
  lines: { text: string; tone: string }[];
  where: { row: number; col: number } | null;
  /** Origin of a Move, so the crew's journey is visible rather than a chip teleporting. */
  whereFrom: { row: number; col: number } | null;
  division: DivisionView[];
  cells: CellView[];
  facilities: FacilityView[];
  hand: string[];
  deck: number;
  departments: string[];
  /** 12 slots; the train number due out at each Stage, or null. */
  timetable: (number | null)[];
  blocked: Impediment[];
  trains: { label: string; where: string }[];
  /** What the bot chose here, why, and what it passed over. Null on engine-driven frames. */
  decision: Decision | null;
  /** A Local Operations turn that changed nothing — the frames worth your attention. */
  wasted: boolean;
};

/**
 * The choice behind a frame.
 *
 * The whole point is `rejected`: the replay could always show what happened, never what COULD have
 * happened, so a daft move was visible but the alternatives it passed over were not — which is
 * exactly what you need to say what it should have done instead.
 */
export type Decision = {
  actor: number;
  chose: string;
  why: string;
  /** Every legal option not taken, grouped by kind with a count. */
  rejected: { kind: string; count: number; detail: string }[];
  totalOptions: number;
};

// ---------------------------------------------------------------------------
// Snapshotting
// ---------------------------------------------------------------------------

const FACILITY_NAMES: Record<string, string> = {
  mineTipple: 'Mine Tipple',
  produceShed: 'Produce Shed',
  grocersWarehouse: "Grocer's Warehouse",
  oilRefinery: 'Oil Refinery',
  powerPlant: 'Power Plant',
};

function facilityView(
  card: { geometry: { kind: string; facility?: string }; facility: unknown },
  officeName: string,
): FacilityView | null {
  const f = (card as { facility: import('../engine/state.ts').Facility | null }).facility;
  // Passenger facilities were excluded entirely, so the Office's green and red slots never
  // appeared — which is why stocking a coach into the green box looked like nothing happening.
  if (!f) return null;
  if (f.kind === 'passenger' && f.porters === 0 && f.capacity.outbound === 0) return null;
  const key = card.geometry.kind === 'facility' ? (card.geometry.facility ?? '') : '';
  return {
    name: f.kind === 'passenger' ? officeName + ' (passengers)' : (FACILITY_NAMES[key] ?? key),
    commodity: facilityCarType(f) ?? '?',
    flow: f.kind === 'passenger'
      ? 'passengers on and off'
      : f.allows.outbound && f.allows.inbound ? 'both' : f.allows.outbound ? 'ships out' : 'receives',
    green: f.outboundBox.map(carLabel),
    greenCap: f.capacity.outbound,
    maw: f.menAtWork.map((l) => (l ? `${l.type} ${l.dir === 'out' ? '→' : '←'}` : null)),
    red: f.inboundBox.map(carLabel),
    redCap: f.capacity.inbound,
    track: f.industryTrack.cars.map(carLabel),
    trackCap: f.industryTrack.length,
    laborers: `${laborersLeft(f)}/${f.laborers}`,
    porters: `${portersLeft(f)}/${f.porters}`,
    canFinish: canFinishHere(f),
    jammed: f.menAtWork.some((l) => l !== null) && !canFinishHere(f),
  };
}

/** Is a car spotted that a load on WORK could actually come off onto (§9.3)? */
function canFinishHere(f: Facility): boolean {
  const pending = f.menAtWork.find((l) => l !== null) ?? f.outboundBox[0];
  if (!pending) return false;
  return f.industryTrack.cars.some((c) => !c.loaded && c.type === pending.type);
}

/**
 * Summarise the choice: what was taken, why, and what was passed over.
 *
 * Options are grouped by kind because a switching turn can offer 40 destinations, and a list that
 * long hides the shape of the decision rather than showing it.
 */
function describeDecision(
  s: GameState,
  actor: number,
  chosen: Intent,
  options: Intent[],
  why: string,
): Decision {
  const groups = new Map<string, Intent[]>();
  for (const o of options) {
    if (o === chosen) continue;
    const list = groups.get(o.type) ?? [];
    list.push(o);
    groups.set(o.type, list);
  }
  const rejected = [...groups.entries()]
    .map(([kind, list]) => ({ kind, count: list.length, detail: sampleDetail(s, kind, list) }))
    .sort((a, b) => b.count - a.count);

  return { actor, chose: describeIntent(s, chosen), why, rejected, totalOptions: options.length };
}

/** A short, concrete example of what a group of rejected options would have done. */
function sampleDetail(s: GameState, kind: string, list: Intent[]): string {
  // Deduplicate by DESCRIPTION. Orientation variants and repeated copies of a card describe
  // identically, so the raw list reads "play Overpass at (0,0)" three times over and hides the
  // actual range of choices — the opposite of what this panel is for.
  const seen = new Set<string>();
  for (const i of list) seen.add(describeIntent(s, i));
  const unique = [...seen];
  const shown = unique.slice(0, 4);
  const more = unique.length - shown.length;
  return shown.join('; ') + (more > 0 ? ` … and ${more} more distinct` : '');
}

/** One readable line for a single intent. */
function describeIntent(s: GameState, i: Intent): string {
  const at = (c: { row: number; col: number }): string => `(${c.row},${c.col})`;
  switch (i.type) {
    case 'localOps.choose':
      return `choose ${i.option}`;
    case 'card.play':
      return `play ${cardName(s, i.cardId)}${i.placement ? ` at ${at(i.placement)}` : ''}`;
    case 'card.discard':
      return `discard ${cardName(s, i.cardId)}`;
    case 'track.lay':
      return `lay ${i.hand === 'none' ? '' : i.hand + '-hand '}${i.geometry} at ${at(i.placement)}`;
    case 'switch.move':
      return `move to ${at(i.to)}${i.reverse ? ' (reverse)' : ''}`;
    case 'switch.dropCars':
      return `drop ${i.count} car(s)`;
    case 'switch.sortConsist':
      return `re-order consist [${i.order.join(',')}]`;
    case 'freightAgent.stockOutbound':
      return `stock a ${i.carType} at ${at(i.at)}`;
    case 'freightAgent.unjam':
      return `unjam ${i.from} at ${at(i.at)}`;
    case 'freightAgent.clearInbound':
      return `clear red box at ${at(i.at)}`;
    case 'laborer.startLoad':
      return `start a load at ${at(i.at)}`;
    case 'laborer.advanceLoad':
      return `advance load in box ${i.box} at ${at(i.at)}`;
    case 'laborer.beginUnload':
      return `begin unloading car ${i.carIndex} at ${at(i.at)}`;
    case 'porter.board':
      return `board passengers at ${at(i.at)}`;
    case 'porter.detrain':
      return `detrain passengers at ${at(i.at)}`;
    case 'newTrain.placeCar':
      return `add ${i.loaded ? 'loaded' : 'empty'} ${i.carType}`;
    case 'mainline.modify':
      return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
    case 'maneuver.redFlags':
      return `Red Flags on ${i.trayId}`;
    case 'maneuver.flyingSwitch':
      return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
    case 'mainline.clearance':
      return i.allow ? 'grant clearance' : 'refuse clearance';
    case 'draw.fromDepartment':
      return `draw the face-up card in slot ${i.slot + 1}`;
    default:
      return i.type;
  }
}

function snapshot(
  s: GameState,
  lines: { text: string; tone: string }[],
  where: { row: number; col: number } | null,
  whereFrom: { row: number; col: number } | null = null,
  decision: Decision | null = null,
  wasted = false,
): Frame {
  const area = areaOf(s, 0);
  const trayAt = new Map<string, string>();
  for (const [id, tray] of s.trays) {
    if (tray.position.at === 'grid') {
      const label = tray.trainNumber === null ? 'crew' : `T${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
      const carrying = tray.consist.length ? ` [${tray.consist.map(carLabel).join(', ')}]` : ' [empty]';
      trayAt.set(`${tray.position.coord.row},${tray.position.coord.col}`, label + carrying);
    }
  }

  const cells: CellView[] = [];
  const facilities: FacilityView[] = [];
  for (const [key, card] of area.grid) {
    const [row, col] = key.split(',').map(Number);
    const g = card.geometry;
    const kind = g.kind;
    let label: string;
    if (g.kind === 'office') label = officeProfile(area.tier).name;
    else if (g.kind === 'limits') label = 'Limits';
    else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? g.facility;
    else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? g.modifier;
    else if (g.kind === 'spaceUse') label = prettyKey(g.key);
    else label = g.geometry === 'sharpCurved' ? 'sharp curve' : g.geometry;

    const fv = facilityView(card as never, officeProfile(area.tier).name);
    if (fv) facilities.push(fv);

    cells.push({
      row: row!,
      col: col!,
      kind,
      label,
      running: row === area.runningRow,
      tray: trayAt.get(key) ?? null,
      cars: (card.facility?.industryTrack.length ? card.facility.industryTrack.cars : card.standing).map(carLabel),
      facility: fv,
    });
  }

  const division: DivisionView[] = s.division.nodes.map((n) => {
    if (n.kind === 'divisionPoint') {
      return {
        kind: 'dp',
        label: n.side === 'west' ? 'West DP' : 'East DP',
        trains: [n.holding.map((id) => trainChip(s, id))],
      };
    }
    if (n.kind === 'mainline') {
      // Crossing time is in Stages now, so a Mainline card shows its terrain and the trains on it
      // with how long each still has to run.
      const name = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.name ?? n.card;
      return {
        kind: 'ml',
        label: name,
        trains: [n.transits.map((t) => {
          const chip = trainChip(s, t.tray);
          return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
        })],
      };
    }
    return {
      kind: 'office',
      label: officeProfile(areaOf(s, n.owner).tier).name,
      trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
    };
  });

  return {
    day: s.clock.day,
    stage: s.clock.stage,
    clock: clockTime(s.clock.stage),
    phase: phaseLabel(s.clock.phase),
    actor: s.clock.currentActor,
    superintendent: s.clock.superintendent,
    revenue: s.players[0]?.revenue ?? 0,
    lines,
    where,
    whereFrom,
    division,
    cells,
    facilities,
    hand: (s.decks.hands.get(0) ?? []).map((id) => cardName(s, id)),
    deck: s.decks.homeOffice.length,
    departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
    timetable: [...s.timetable],
    decision,
    wasted,
    blocked: impediments(s, 0),
    trains: [...s.trays.values()].map((t) => ({
      label: t.trainNumber === null ? 'local crew' : `Train ${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
      where:
        t.position.at === 'divisionPoint'
          ? `${t.position.side} Division Point`
          : t.position.at === 'mainline'
            ? `Mainline card ${t.position.index}`
            : `Office Area (${t.position.coord.row},${t.position.coord.col})`,
    })),
  };
}

/** A card id turned into something a person can read. */
export function cardName(s: GameState, id: string): string {
  const k = s.cards.get(id)?.kind;
  if (!k) return 'a card';
  switch (k.kind) {
    case 'timetabledTrain':
      return `Train ${k.number}`;
    case 'extraTrain':
      return `Extra X${k.number}`;
    case 'office':
      return `${k.tier.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase())} upgrade`;
    case 'freightFacility':
      return FACILITY_NAMES[k.facility] ?? k.facility;
    case 'modifier':
      return MODIFIER_NAMES[k.modifier] ?? k.modifier;
    case 'track':
      return k.geometry === 'sharpCurved' ? 'Sharp curve' : `${k.geometry} track`;
    case 'spaceUse':
    case 'enhancement':
    case 'mainlineModifier':
    case 'maneuver':
    case 'action':
      return prettyKey(k.key);
  }
}

/** camelCase key → readable name, for the card categories that carry only a key. */
function prettyKey(key: string): string {
  return key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
}

const MODIFIER_NAMES: Record<string, string> = {
  teamTrack: 'Team Track',
  loadingDock: 'Loading Dock',
  storageShed: 'Storage Shed',
  extraPlatform: 'Extra Platform',
  sectionGang: 'Section Gang',
};

/** A train on the board, with what it is carrying — otherwise a run looks identical empty or full. */
function trainChip(s: GameState, id: string): TrainChip {
  const t = s.trays.get(id);
  if (!t) return { label: id, consist: [] };
  return {
    label: t.trainNumber === null ? 'crew' : `T${t.trainIsExtra ? 'X' : ''}${t.trainNumber}`,
    consist: t.consist.map(carLabel),
  };
}

// ---------------------------------------------------------------------------
// Recording
// ---------------------------------------------------------------------------

export type Recording = { seed: number; length: GameLength; frames: Frame[]; outcome: string };

export function record(seed: number, length: GameLength, maxSteps = 100_000): Recording {
  const config: GameConfig = {
    mode: 'solitaire',
    victory: 'highestAfterDays',
    length,
    optionalRules: {
      reducedVisibility: false,
      sisterTrains: false,
      employeeRotation: false,
      emergencyToolbox: false,
    },
  };
  const s = createGame({ id: `replay-${seed}`, seed, config, playerNames: ['player'] });
  const frames: Frame[] = [];
  const narrateCtx = { cardName: (id: string) => cardName(s, id) };

  // Tracks whether a phase did anything, so an empty one can say so rather than ending silently.
  let didSomething = false;
  // The choice that produced the events about to be pushed.
  let pendingDecision: Decision | null = null;

  const push = (events: GameEvent[]): void => {
    const visible = events.filter(isVisible);
    if (visible.length === 0) return;

    const lines: { text: string; tone: string }[] = [];
    for (const e of visible) {
      if (e.type === 'phaseBegan') didSomething = false;
      if (e.type === 'phaseEnded' && !didSomething) {
        lines.push({ text: idleNote(e.phase), tone: 'quiet' });
      }
      if (
        e.type !== 'phaseBegan' &&
        e.type !== 'phaseEnded' &&
        e.type !== 'stageBegan'
      ) {
        didSomething = true;
      }
      const n = narrate(e, narrateCtx);
      lines.push({ text: n.text, tone: n.tone });
    }
    const where = visible.map((e) => narrate(e, narrateCtx)).find((n) => n.where)?.where ?? null;
    const moved = visible.find((e) => e.type === 'trayMoved');
    const from = moved && moved.type === 'trayMoved' ? moved.from : null;
    const d = pendingDecision;
    pendingDecision = null;
    // A Local Operations turn that ended with nothing but the turn ending is a wasted action —
    // six Moves and a card play spent on nothing. Among 600+ frames these are invisible unless
    // marked, and they are the ones worth reviewing.
    const wasted =
      d !== null &&
      (d.chose === 'switch.end' || d.chose === 'draw.end' || d.chose === 'loadUnload.end') &&
      visible.every((e) => e.type === 'phaseEnded' || e.type === 'actorChanged');
    frames.push(snapshot(s, lines, where ?? null, from, d, wasted));
  };

  frames.push(
    snapshot(s, [{ text: 'Game begins — the Division has just been spiked down.', tone: 'clock' }], null),
  );

  for (let step = 0; step < maxSteps; step++) {
    const r = advance(s);
    push(r.events);
    if (s.status === 'finished') break;
    if (!r.needsInput) continue;

    const actor = s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
    if (actor === null) break;
    const options = legalActions(s, actor);
    if (options.length === 0) break;
    const chosen = developerBot.choose(s, actor, options);
    const decision = describeDecision(s, actor, chosen, options, lastChoiceReason());
    const applied = applyIntent(s, actor, chosen);
    if (!applied.ok) break;
    pendingDecision = decision;
    push(applied.events);
  }

  const o = s.outcome;
  return {
    seed,
    length,
    frames,
    outcome: o ? `${o.result} — ${o.reason} · final Revenue ${s.players[0]?.revenue ?? 0}` : 'unfinished',
  };
}

// ---------------------------------------------------------------------------
// HTML
// ---------------------------------------------------------------------------

export function renderHtml(rec: Recording): string {
  const target = lengthProfile(rec.length);
  return `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Station Master — replay seed ${rec.seed}</title>
<style>
:root{--bg:#14161a;--fg:#e8e6e3;--dim:#8b9199;--line:#2c3138;--panel:#1b1f25;
--good:#5fd08a;--bad:#ff7a70;--clock:#7fb8ff;--warn:#ffc46b;--green:#2f6b47;--red:#6b3230;--maw:#2a4a6b}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace}
header{padding:10px 14px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5}
h1{font-size:14px;margin:0 0 6px;font-weight:600}
.bar{display:flex;gap:18px;flex-wrap:wrap;align-items:baseline}
.big{font-size:19px;font-weight:700}
.dim{color:var(--dim)}
.wrap{display:grid;grid-template-columns:minmax(0,3fr) minmax(0,2fr);gap:14px;padding:14px}
@media(max-width:900px){.wrap{grid-template-columns:1fr}}
section{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:10px 12px;margin-bottom:14px}
h2{font-size:11px;letter-spacing:.09em;text-transform:uppercase;color:var(--dim);margin:0 0 8px;font-weight:600}
.div-strip{display:flex;gap:6px;overflow-x:auto;padding-bottom:4px}
.node{border:1px solid var(--line);border-radius:5px;padding:6px 8px;min-width:96px;background:#20252c}
.node.office{border-color:var(--clock)}
.regions{display:flex;gap:4px;margin-top:5px}
.region{flex:1;min-height:22px;border:1px dashed var(--line);border-radius:3px;display:flex;align-items:center;justify-content:center;font-size:11px}
.chip{background:var(--clock);color:#0d1117;border-radius:3px;padding:1px 5px;font-weight:700;font-size:11px}
.grid{display:grid;gap:5px;overflow-x:auto}
.cell{border:1px solid var(--line);border-radius:5px;padding:6px;min-height:64px;background:#20252c}
.cell.run{border-color:#4a545f;background:#252b33}
.cell.hl{outline:2px solid var(--warn);outline-offset:1px}
.cell.from{outline:2px dashed #6b7480;outline-offset:1px}
/* The crew is the thing you follow around the yard, so it gets the loudest treatment on the card. */
.crew{background:var(--warn);color:#0d1117;font-weight:700;border-radius:4px;padding:3px 6px;
margin-top:5px;font-size:11px;line-height:1.35}
.cell .nm{font-weight:700;font-size:11px}
.cell .cars{color:var(--dim);font-size:10.5px;margin-top:3px}
.cell .tray{display:inline-block;margin-top:4px}
.fac{border:1px solid var(--line);border-radius:5px;padding:8px;margin-bottom:8px;background:#20252c}
.boxes{display:flex;gap:6px;flex-wrap:wrap;margin-top:6px;align-items:center}
.box{border-radius:3px;padding:3px 7px;font-size:11px;border:1px solid var(--line)}
.box.g{background:var(--green)}.box.r{background:var(--red)}.box.m{background:var(--maw)}
.box.empty{background:transparent;color:var(--dim)}
.log{max-height:230px;overflow:auto}
.line{padding:2px 0;border-bottom:1px solid #23272e}
.t-good{color:var(--good)}.t-bad{color:var(--bad)}.t-clock{color:var(--clock);font-weight:700}
.t-quiet{color:var(--dim)}.t-plain{color:var(--fg)}
.blocked li{margin-bottom:4px}
.sev-stuck{color:var(--bad)}.sev-risk{color:var(--warn)}.sev-waiting{color:var(--dim)}
/* FIXED, not sticky: a sticky footer shifts as panel heights change, so the mouse cannot stay
   parked on one button and keep clicking. */
footer{position:fixed;left:0;right:0;bottom:0;background:var(--bg);border-top:1px solid var(--line);
padding:9px 14px;display:flex;gap:8px;align-items:center;flex-wrap:nowrap;z-index:10;overflow-x:auto}
body{padding-bottom:64px}
button{background:#2a3038;color:var(--fg);border:1px solid var(--line);border-radius:5px;padding:5px 11px;
font:inherit;cursor:pointer}
button:hover{background:#343b45}
input[type=range]{flex:1;min-width:180px}
.legend{font-size:11px;color:var(--dim);padding:0 14px 14px}
kbd{background:#2a3038;border:1px solid var(--line);border-radius:3px;padding:0 4px;font-size:10px}
.tt{display:grid;grid-template-columns:repeat(12,1fr);gap:3px}
.tt div{border:1px solid var(--line);border-radius:3px;padding:3px 2px;text-align:center;font-size:10.5px}
.tt div.due{background:var(--clock);color:#0d1117;font-weight:700}
.tt div.now{outline:2px solid var(--warn)}
.card{display:inline-block;border:1px solid var(--line);border-radius:3px;padding:1px 6px;margin:2px 3px 0 0;font-size:11px;background:#20252c}
.consist{font-size:10px;color:var(--dim);margin-top:2px}
.mini{margin-top:4px;font-size:9.5px;line-height:1.8}
.mini .box{padding:0 4px;font-size:9.5px}
.cell.office{border-color:var(--clock)}
.cell.modifier{border-style:dashed;opacity:.85}

.chose{font-size:15px;margin-bottom:4px}
.why{font-style:italic;opacity:.85;margin-bottom:2px}
.rejected{list-style:none;margin:0;padding:0;max-height:210px;overflow:auto}
.rejected li{padding:3px 0;border-top:1px solid rgba(128,128,128,.25);font-size:12px}
.wasted{background:#b03030;color:#fff;font-weight:bold;padding:3px 6px;margin-bottom:5px;border-radius:3px}
.fstat{margin-top:4px;font-size:11px;padding:2px 5px;border-radius:3px;display:inline-block}
.fstat.good{background:rgba(40,140,60,.28)}
.fstat.bad{background:rgba(190,50,50,.38);font-weight:bold}
.fstat.idle{opacity:.6}
.wastedmark{color:#e06060;font-weight:bold}
</style></head><body>

<header>
  <h1>Station Master — replay · seed ${rec.seed} · ${rec.length} (target ${target.target} over ${target.days} Days) · ${esc(rec.outcome)}</h1>
  <div class="bar">
    <span class="big" id="when">—</span>
    <span>phase <b id="phase">—</b></span>
    <span class="dim">actor <span id="actor">—</span> · fedora <span id="super">—</span></span>
    <span>revenue <b class="big" id="rev">0</b></span>
    <span class="dim">deck <span id="deck">0</span></span>
    <span class="dim">frame <span id="fno">0</span>/<span id="ftot">0</span></span>
  </div>
</header>

<div class="wrap">
  <div>
    <section><h2>Division — west to east</h2><div class="div-strip" id="division"></div></section>
    <section><h2>Office Area</h2><div class="grid" id="grid"></div></section>
    <section><h2>Facilities</h2><div id="facs"></div></section>
    <section><h2>Timetable — which train is due out at each Stage</h2><div id="tt"></div></section>
  </div>
  <div>
    <section><h2>What just happened</h2><div id="now"></div></section>
    <section><h2>Decision — what it chose, and what it passed over</h2><div id="decision"></div></section>
    <section><h2>Cards</h2>
      <div><span class="dim">your hand:</span> <span id="hand">—</span></div>
      <div style="margin-top:5px"><span class="dim">face-up Department slots:</span> <span id="depts">—</span></div>
    </section>
    <section><h2>Blocked — why nothing is moving</h2><ul class="blocked" id="blocked"></ul></section>
    <section><h2>Trains in play</h2><div id="trains" class="dim"></div></section>
    <section><h2>History</h2><div class="log" id="log"></div></section>
  </div>
</div>

<div class="legend">
  <b>Keyboard:</b> <kbd>←</kbd>/<kbd>→</kbd> step · <kbd>space</kbd> play/pause ·
  <kbd>Home</kbd>/<kbd>End</kbd> first/last · <kbd>S</kbd> next Stage · <kbd>R</kbd> next revenue change ·
  <kbd>C</kbd> next collision · <kbd>W</kbd> next wasted turn · <kbd>J</kbd> next jam.<br>
  Running Track is the lighter row. <span class="chip">T4</span> is a train on the Division;
  <span style="background:var(--warn);color:#0d1117;font-weight:700;border-radius:3px;padding:0 4px">🚂 T4</span>
  is the crew in your yard — it carries the whole train with it as it moves, and a dashed outline
  marks where it came from. Green box = outbound loads waiting ·
  MEN / AT / WORK = the three-step loading track (→ outbound, ← inbound) · red box = delivered loads.
  Laborers and Porters show remaining/total for this Stage.
</div>

<footer>
  <button id="first">⏮ start</button>
  <button id="back">◀ step</button>
  <button id="play">▶ play</button>
  <button id="fwd">step ▶</button>
  <button id="stage">next Stage ⏭</button>
  <button id="money">next revenue 💰</button>
  <button id="crash">next collision ⚠</button>
  <button id="waste">next wasted turn 🗑</button>
  <button id="jam">next jam 🔒</button>
  <input type="range" id="scrub" min="0" value="0">
  <select id="speed">
    <option value="3000">extra slow</option>
    <option value="1200">slow</option>
    <option value="250" selected>normal</option>
    <option value="90">fast</option>
    <option value="20">very fast</option>
  </select>
</footer>

<script>
const FRAMES = ${JSON.stringify(rec.frames)};
let i = 0, timer = null;
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s).replace(/[&<>]/g, (c) => ({'&':'&amp;','<':'&lt;','>':'&gt;'}[c]));

// The green / MEN|AT|WORK / red boxes drawn on the card itself, so a Freight Agent action or a
// Porter boarding shows up where it happened rather than only in a side panel.
function miniFacility(x) {
  let h = '<div class="mini">';
  if (x.greenCap > 0) h += '<span class="dim">grn </span>' + boxes(x.green, x.greenCap, 'g');
  if (x.trackCap > 0) h += '<br><span class="dim">work </span>' +
    x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('');
  if (x.redCap > 0) h += '<br><span class="dim">red </span>' + boxes(x.red, x.redCap, 'r');
  return h + '</div>';
}

function boxes(items, cap, cls) {
  let h = '';
  for (let k = 0; k < Math.max(cap, items.length); k++) {
    const v = items[k];
    h += '<span class="box ' + (v ? cls : 'empty') + '">' + (v ? esc(v) : '·') + '</span>';
  }
  return h || '<span class="dim">—</span>';
}

function render() {
  const f = FRAMES[i];
  $('when').textContent = 'Day ' + f.day + ' · Stage ' + f.stage + ' · ' + f.clock;
  $('phase').textContent = f.phase;
  $('actor').textContent = f.actor === null ? 'automatic' : 'P' + f.actor;
  $('super').textContent = 'P' + f.superintendent;
  $('rev').textContent = f.revenue;
  $('rev').className = 'big ' + (f.revenue < 0 ? 't-bad' : f.revenue > 0 ? 't-good' : '');
  $('deck').textContent = f.deck;
  $('hand').innerHTML = f.hand.length
    ? f.hand.map((c) => '<span class="card">' + esc(c) + '</span>').join('')
    : '<span class="dim">empty</span>';
  $('depts').innerHTML = f.departments.map((c) => '<span class="card">' + esc(c) + '</span>').join('');
  $('tt').innerHTML = '<div class="tt">' + f.timetable.map((t, k) =>
    '<div class="' + (t !== null ? 'due ' : '') + (k + 1 === f.stage ? 'now' : '') + '">' +
    'S' + (k + 1) + '<br>' + (t !== null ? 'T' + t : '·') + '</div>').join('') + '</div>' +
    '<div class="dim" style="margin-top:5px">The NUMBER of a train is its seniority and direction ' +
    '(odd = westbound, even = eastbound). The Stage it departs is set by a 1D12 roll when its card ' +
    'was played — so Train 8 leaving at Stage 6 is normal.</div>';
  $('fno').textContent = i;
  $('scrub').value = i;

  $('division').innerHTML = f.division.map((n) =>
    '<div class="node ' + (n.kind === 'office' ? 'office' : '') + '"><div class="nm">' + esc(n.label) + '</div>' +
    '<div class="regions">' + n.trains.map((r) =>
      '<div class="region">' + r.map((t) =>
        '<span class="chip">' + esc(t.label) + '</span>' +
        '<div class="consist">' + (t.consist.length ? esc(t.consist.join(', ')) : 'empty') + '</div>'
      ).join('') + '</div>').join('') +
    '</div></div>').join('');

  const rows = f.cells.map((c) => c.row), cols = f.cells.map((c) => c.col);
  const r0 = Math.min(...rows), r1 = Math.max(...rows), c0 = Math.min(...cols), c1 = Math.max(...cols);
  const g = $('grid');
  g.style.gridTemplateColumns = 'repeat(' + (c1 - c0 + 1) + ', minmax(110px, 1fr))';
  let cellsHtml = '';
  for (let r = r1; r >= r0; r--) {
    for (let c = c0; c <= c1; c++) {
      const cell = f.cells.find((x) => x.row === r && x.col === c);
      if (!cell) { cellsHtml += '<div></div>'; continue; }
      const hl = f.where && f.where.row === r && f.where.col === c;
      const wasHere = f.whereFrom && f.whereFrom.row === r && f.whereFrom.col === c;
      cellsHtml += '<div class="cell ' + (cell.running ? 'run ' : '') + (cell.kind === 'office' ? 'office ' : '') +
        (cell.kind === 'modifier' ? 'modifier ' : '') + (hl ? 'hl ' : '') + (wasHere ? 'from' : '') + '">' +
        '<div class="nm">' + esc(cell.label) + '</div>' +
        '<div class="dim" style="font-size:10px">(' + r + ',' + c + ')</div>' +
        (cell.tray ? '<div class="crew">🚂 ' + esc(cell.tray) + '</div>' : '') +
        (cell.facility ? miniFacility(cell.facility) : '') +
        (cell.cars.length ? '<div class="cars">siding: ' + cell.cars.map(esc).join('<br>') + '</div>' : '') +
        '</div>';
    }
  }
  g.innerHTML = cellsHtml;

  $('facs').innerHTML = f.facilities.length === 0
    ? '<span class="dim">no freight facilities built yet</span>'
    : f.facilities.map((x) =>
      '<div class="fac"><b>' + esc(x.name) + '</b> <span class="dim">' + esc(x.commodity) + ' · ' + esc(x.flow) +
      ' · laborers ' + esc(x.laborers) + ' · porters ' + esc(x.porters) + '</span>' +
      '<div class="boxes"><span class="dim">green</span>' + boxes(x.green, x.greenCap, 'g') + '</div>' +
      '<div class="boxes"><span class="dim">MEN|AT|WORK</span>' +
        x.maw.map((m) => '<span class="box ' + (m ? 'm' : 'empty') + '">' + (m ? esc(m) : '·') + '</span>').join('') + '</div>' +
      '<div class="boxes"><span class="dim">red</span>' + boxes(x.red, x.redCap, 'r') + '</div>' +
      '<div class="boxes"><span class="dim">siding</span>' + boxes(x.track, x.trackCap, 'g') + '</div>' +
      '<div class="fstat ' + (x.jammed ? 'bad' : (x.canFinish ? 'good' : 'idle')) + '">' +
        (x.jammed
          ? 'JAMMED — a load is on WORK with no car spotted to receive it; the industry track is locked'
          : (x.canFinish
            ? 'ready — a matching empty car is spotted, so a load can finish'
            : 'no car spotted — starting a load here would jam it')) +
      '</div>' +
      '</div>').join('');

  $('now').innerHTML = f.lines.map((l) => '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>').join('');

  var d = f.decision;
  if (!d) {
    $('decision').innerHTML = '<span class="dim">no choice here — the engine advanced on its own</span>';
  } else {
    var h = '';
    if (f.wasted) h += '<div class="wasted">WASTED TURN — the whole action produced no change</div>';
    h += '<div class="chose"><span class="dim">chose:</span> <b>' + esc(d.chose) + '</b></div>';
    h += '<div class="why">why: ' + esc(d.why) + '</div>';
    h += '<div class="dim" style="margin:6px 0 3px">passed over ' + (d.totalOptions - 1) +
         ' other legal option' + ((d.totalOptions - 1) === 1 ? '' : 's') + ':</div>';
    if (d.rejected.length === 0) h += '<div class="dim">none — this was the only legal action</div>';
    else h += '<ul class="rejected">' + d.rejected.map(function (r) {
      return '<li><b>' + esc(r.kind) + '</b> <span class="dim">x' + r.count + '</span><br>' +
             '<span class="dim">' + esc(r.detail) + '</span></li>';
    }).join('') + '</ul>';
    $('decision').innerHTML = h;
  }
  $('blocked').innerHTML = f.blocked.length === 0
    ? '<li class="dim">nothing blocked</li>'
    : f.blocked.map((b) => '<li class="sev-' + b.severity + '"><b>' + esc(b.where) + '</b> — ' + esc(b.why) + '</li>').join('');
  $('trains').innerHTML = f.trains.length === 0
    ? 'no trains running'
    : f.trains.map((t) => esc(t.label) + ' — ' + esc(t.where)).join('<br>');

  let hist = '';
  for (let k = Math.max(0, i - 24); k <= i; k++) {
    for (const l of FRAMES[k].lines) hist += '<div class="line t-' + l.tone + '">' + esc(l.text) + '</div>';
  }
  $('log').innerHTML = hist;
  $('log').scrollTop = $('log').scrollHeight;
}

function go(n) { i = Math.max(0, Math.min(FRAMES.length - 1, n)); render(); }
function findNext(pred) { for (let k = i + 1; k < FRAMES.length; k++) if (pred(FRAMES[k], FRAMES[k - 1])) return go(k); go(FRAMES.length - 1); }

$('ftot').textContent = FRAMES.length - 1;
$('scrub').max = FRAMES.length - 1;
$('scrub').oninput = (e) => go(+e.target.value);
$('first').onclick = () => go(0);
$('back').onclick = () => go(i - 1);
$('fwd').onclick = () => go(i + 1);
$('stage').onclick = () => findNext((f, p) => f.stage !== p.stage || f.day !== p.day);
$('money').onclick = () => findNext((f, p) => f.revenue !== p.revenue);
$('crash').onclick = () => findNext((f) => f.lines.some((l) => /COLLISION|collision/.test(l.text)));
$('waste').onclick = () => findNext((f) => f.wasted);
$('jam').onclick = () => findNext((f) => f.facilities.some((x) => x.jammed));
$('play').onclick = () => {
  if (timer) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
  $('play').textContent = '⏸ pause';
  timer = setInterval(() => {
    if (i >= FRAMES.length - 1) { clearInterval(timer); timer = null; $('play').textContent = '▶ play'; return; }
    go(i + 1);
  }, +$('speed').value);
};
$('speed').onchange = () => { if (timer) { $('play').click(); $('play').click(); } };
document.onkeydown = (e) => {
  if (e.key === 'ArrowRight') go(i + 1);
  else if (e.key === 'ArrowLeft') go(i - 1);
  else if (e.key === 'Home') go(0);
  else if (e.key === 'End') go(FRAMES.length - 1);
  else if (e.key === 's' || e.key === 'S') $('stage').click();
  else if (e.key === 'r' || e.key === 'R') $('money').click();
  else if (e.key === 'c' || e.key === 'C') $('crash').click();
  else if (e.key === 'w' || e.key === 'W') $('waste').click();
  else if (e.key === 'j' || e.key === 'J') $('jam').click();
  else if (e.key === ' ') { e.preventDefault(); $('play').click(); }
};
render();
</script></body></html>`;
}

function esc(s: string): string {
  return s.replace(/[&<>]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;' })[c] ?? c);
}

// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------

const isMain = process.argv[1]?.endsWith('replay.ts') ?? false;

if (isMain) {
  const arg = (name: string, dflt: string): string => {
    const i = process.argv.indexOf(`--${name}`);
    return i >= 0 ? (process.argv[i + 1] ?? dflt) : dflt;
  };
  const seed = Number(arg('seed', '1234'));
  const length = arg('length', 'standard') as GameLength;
  const out = arg('out', 'replay.html');

  const rec = record(seed, length);
  writeFileSync(out, renderHtml(rec));
  const kb = (Buffer.byteLength(renderHtml(rec)) / 1024).toFixed(0);
  console.log(`wrote ${out} — ${rec.frames.length} frames, ${kb} KB`);
  console.log(`seed ${seed} · ${length} · ${rec.outcome}`);
}
