/**
 * The view-model: what a Station Master position LOOKS like.
 *
 * Split out of `replay.ts` so the playable browser build can use it. `replay.ts` writes files and
 * reads `process.argv`, so importing it pulled `node:fs` into the bundle — and the engine's whole
 * claim to run client-side rests on nothing in the import graph needing Node.
 *
 * Nothing here decides anything. It turns a `GameState` into boxes and labels, and turns an
 * `Intent` into a sentence. The live game and the replay both render from this, so they cannot
 * drift into two different pictures of the same board.
 */

import { areaOf, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import {
  ACTION_CARDS,
  ENHANCEMENT_CARDS,
  MAINLINE_MODIFIER_CARDS,
  MAINLINE_PROFILES,
  MANEUVER_CARDS,
  OFFICE_ORDER,
  SPACE_USE_CARDS,
  industryProfile,
  modifierProfile,
  lengthProfile,
  officeProfile,
  trainProfile,
} from '../engine/content.ts';
import type { Intent } from '../engine/intents.ts';
import type { Facility, GameState, TrackCard } from '../engine/state.ts';
import type { TrackGeometry } from '../engine/content.ts';
import { variantsFor } from '../engine/track.ts';
import type { Impediment } from './narrate.ts';
import { carLabel, clockTime, impediments, phaseLabel } from './narrate.ts';

export type CellView = {
  row: number;
  col: number;
  kind: string;
  label: string;
  running: boolean;
  /**
   * Enhancements laid ON this card. They were invisible: playing an Overpass onto the Office
   * announced itself in the log and then changed nothing on the board, so a permanent change to how
   * the district works left no trace you could see.
   */
  enhancements: string[];
  tray: string | null;
  cars: string[];
  facility: FacilityView | null;
  /**
   * What this card DOES, now that it is on the board.
   *
   * A played card becomes a cell with a name on it and nothing else — "turnout", "Freight House",
   * "waiting area" — so the explanation that was visible while it sat in hand disappears at exactly
   * the moment it starts mattering.
   */
  what: string;
};

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[][];
  /** Modifiers laid on a Mainline card (Brakeman, Helpers, Realignment …). */
  modifiers: string[];
  /**
   * Which way a Heavy Grade climbs. The card prints "(Up)" and "Player sets orientation", so the
   * direction is a property of the placed card — and without showing it, a Brakeman or Helpers card
   * on that grade has no visible meaning.
   */
  gradeUp: string | null;
};

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[];
  /** What each hand card does, in the same order — names alone are not a playable hand. */
  handWhat: string[];
  deck: number;
  departments: string[];
  /** What each face-up Department card does. */
  departmentsWhat: string[];
  /** 12 slots; the train number due out at each Stage, or null. */
  timetable: (number | null)[];
  blocked: Impediment[];
  trains: { label: string; where: string }[];
  /**
   * Where you stand against the target. Nothing on screen said what the game was FOR, so a player
   * had the score but no way to know whether it was good.
   */
  objective: { target: number; days: number; daysLeft: number; onPace: boolean; note: string };
  /**
   * What is left of the player's personal track supply (§12.2). Track is NOT drawn from the deck —
   * each player starts with 26 pieces and lays at most one a turn, so "how many straights have I
   * got left" is a real planning question the board could not answer.
   */
  trackSupply: { piece: string; left: number }[];
  /** 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] ?? prettyKey(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.
 */
export 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. */
export 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':
      // The most consequential decision of the Stage, and it was labelled "choose switch". Say what
      // each option actually spends and buys.
      return i.option === 'switch'
        ? 'SWITCH — six Moves to shunt cars: spot empties at industries, collect loads'
        : i.option === 'draw'
          ? 'DRAW — take a card and play one, and you may lay a piece of track'
          : 'FREIGHT AGENT — one car moved to or from a facility, or clear a jam';
    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 '}${geometryLabel(i.geometry)} ` +
        `at ${at(i.placement)}${variantLabel(i.geometry, i.variant)}`
      );
    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':
      // carLabel knows a caboose carries the crew, not freight. Formatting it here by hand put
      // "add loaded caboose" on a button.
      return `add ${carLabel({ type: i.carType, loaded: i.loaded })}`;
    case 'mainline.modify':
      return `${cardName(s, i.cardId)} on Mainline card ${i.node}`;
    case 'maneuver.redFlags':
      return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
    case 'maneuver.flyingSwitch':
      return `Flying Switch ${i.count} car(s) into ${at(i.to)}`;
    case 'mainline.clearance': {
      // The §8.1 ruling is the sharpest decision in the game and read "grant clearance" — no hint
      // that granting it risks a rear-ender, or that refusing merely costs time.
      const pending = s.clock.pendingDecision;
      const who = pending ? trainName(s, pending.train) : 'the train';
      const ahead = pending ? trainName(s, pending.occupiedBy) : 'the train ahead';
      // NOT "risks a collision, −5". A rear-end on a Mainline card is described by §10 and is what
      // ABS Signals exists to prevent, but no such collision is implemented — granting clearance is
      // currently free. Saying otherwise invents a consequence the engine will never deliver.
      // See implications.md §10 Q13.
      return i.allow
        ? `ALLOW — ${who} follows ${ahead} onto the same Mainline card, closing up behind it`
        : `HOLD — ${who} waits where it is, losing the Stage but keeping the line clear`;
    }
    case 'draw.fromDepartment': {
      // Naming the card is the whole point of a FACE-UP slot: "slot 2" tells a player nothing, and
      // the choice between a visible card and a blind draw is unmakeable without it.
      const id = s.decks.departments[i.slot];
      return id ? `take ${cardName(s, id)} (face-up slot ${i.slot + 1})` : `slot ${i.slot + 1} (empty)`;
    }
    case 'draw.fromHomeOffice':
      return `draw blind from the Home Office deck (${s.decks.homeOffice.length} left)`;
    case 'draw.end':
      return 'done drawing — end my turn';
    case 'switch.end':
      return 'done switching — end my turn';
    case 'loadUnload.end':
      return 'done working — end the Load/Unload phase';
    case 'newTrain.passCar':
      return 'add no more cars to this train';
    case 'newTrain.secondSection':
      return `run a Second Section behind Train ${i.trainNumber}`;
    case 'redFlag.play':
      return 'play your red flag';
    case 'maneuver.redFlags':
      return `set Red Flags to protect ${trainName(s, i.trayId)} — an approaching train must stop short`;
    default: {
      // Every Intent now has a sentence, so `i` narrows to never here. Keeping the assignment makes
      // that a COMPILE error the day someone adds an intent without describing it — the playable UI
      // labels its buttons from this function, so a missing case ships as a button reading
      // "maneuver.poling".
      const unhandled: never = i;
      return String((unhandled as { type: string }).type);
    }
  }
}

/**
 * Build the view-model for a state. Shared with the playable web app so the live game and the
 * replay cannot drift into two different pictures of the same board.
 */
export 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';
    // Fall back to prettyKey, never the raw key. The lookup tables exist for names prettyKey cannot
    // guess ("Grocer's Warehouse"), not as the only route to a readable label — leaving the raw key
    // as the fallback put `refinery` and `viscosityBreakers` on the board.
    else if (g.kind === 'facility') label = FACILITY_NAMES[g.facility] ?? prettyKey(g.facility);
    else if (g.kind === 'modifier') label = MODIFIER_NAMES[g.modifier] ?? prettyKey(g.modifier);
    else if (g.kind === 'spaceUse') label = prettyKey(g.key);
    else label = geometryLabel(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,
      what: cellDescription(card, officeProfile(area.tier).name, row === area.runningRow),
      enhancements: card.enhancements.map(prettyKey),
      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))],
        modifiers: [],
        gradeUp: null,
      };
    }
    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;
      const isGrade = MAINLINE_PROFILES.find((m) => m.kind === n.card)?.speed.kind === 'grade';
      return {
        kind: 'ml',
        label: name,
        trains: [n.transits.map((t) => {
          const chip = trainChip(s, t.tray);
          return { ...chip, label: `${chip.label} (${t.stagesRemaining})` };
        })],
        modifiers: [
          ...(n.modifiers ?? []).map(prettyKey),
          ...(n.absSignals ? ['ABS Signals'] : []),
        ],
        gradeUp: isGrade ? (n.gradeUp ?? 'east') : null,
      };
    }
    return {
      kind: 'office',
      label: officeProfile(areaOf(s, n.owner).tier).name,
      trains: [areaOf(s, n.owner).adOccupancy.map((id) => trainChip(s, id))],
      modifiers: [],
      gradeUp: null,
    };
  });

  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)),
    handWhat: (s.decks.hands.get(0) ?? []).map((id) => cardDescription(s, id)),
    deck: s.decks.homeOffice.length,
    departments: s.decks.departments.map((id) => (id ? cardName(s, id) : '—')),
    departmentsWhat: s.decks.departments.map((id) => (id ? cardDescription(s, id) : '')),
    timetable: [...s.timetable],
    decision,
    wasted,
    objective: objectiveOf(s),
    trackSupply: [...area.trackSupply.entries()]
      .map(([key, left]) => {
        const [geometry, hand] = key.split(':');
        return { piece: `${hand === 'none' ? '' : hand + '-hand '}${geometryLabel(geometry ?? '')}`, left };
      })
      .sort((a, b) => a.piece.localeCompare(b.piece)),
    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`;
    // Fall back to prettyKey, never to the raw key: an unnamed card showed as "rotaryDumps" on a
    // button a player is meant to read. The lookup tables are for names prettyKey cannot guess
    // (Grocer's Warehouse), not the only source of a readable label.
    case 'freightFacility':
      return FACILITY_NAMES[k.facility] ?? prettyKey(k.facility);
    case 'modifier':
      return MODIFIER_NAMES[k.modifier] ?? prettyKey(k.modifier);
    case 'track':
      return `${geometryLabel(k.geometry)} track`;
    case 'spaceUse':
    case 'enhancement':
    case 'mainlineModifier':
    case 'maneuver':
    case 'action':
      return prettyKey(k.key);
  }
}

/**
 * What a card actually DOES, in one line.
 *
 * The hand showed names only, so "Steam Turbines" or "Facing Point Locks" told a player nothing
 * about the effect — the difference between playing the game and clicking hopefully. Every fact
 * here already existed in the content tables; none of it was reaching the screen.
 */
export function cardDescription(s: GameState, id: string): string {
  const k = s.cards.get(id)?.kind;
  if (!k) return '';

  switch (k.kind) {
    case 'timetabledTrain':
    case 'extraTrain': {
      const t = trainProfile(k.number, k.kind === 'extraTrain');
      if (!t) return '';
      const parts: string[] = [];
      if (t.consist.freight > 0) {
        const types = t.consist.freightTypes;
        parts.push(`${t.consist.freight} freight${types ? ` (${types.join('/')})` : ''}`);
      }
      if (t.consist.coach > 0) parts.push(`${t.consist.coach} coach`);
      if (t.consist.caboose > 0) parts.push('caboose');
      const extra = k.kind === 'extraTrain' ? 'runs ONCE, unscheduled' : 'runs every Day once scheduled';
      // Several Extras leave the direction to the player, so "playerChoicebound" is not a word.
      const dir = t.direction === 'playerChoice' ? 'either direction' : `${t.direction}bound`;
      const rule = t.rules.note ? ` · ${t.rules.note}` : '';
      return `${t.name} · ${t.speed}, ${dir} · ${parts.join(' + ') || 'no cars'} · ${extra}${rule}`;
    }
    case 'office': {
      const p = officeProfile(k.tier);
      // Upgrades are strictly sequential (Gap 3b), so a Station card is dead weight until the
      // Office is a Depot. Without saying so, the card looks playable and simply never is.
      const needs = OFFICE_ORDER[OFFICE_ORDER.indexOf(k.tier) - 1];
      const prereq = needs ? ` · requires the Office to be a ${prettyKey(needs)} first` : '';
      return (
        `upgrade the Office: ${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'}, ` +
        `${p.porters} porter${p.porters === 1 ? '' : 's'}, ` +
        `${p.passengerOut} passenger out / ${p.passengerIn} in` +
        (p.isControlPoint ? ' · a Control Point' : '') +
        prereq
      );
    }
    case 'freightFacility': {
      const f = industryProfile(k.facility);
      const flow = f.flow === 'both' ? 'ships out AND receives' : f.flow === 'outbound' ? 'ships out' : 'receives';
      const lock = f.lockouts.length
        ? ` · cannot share a district with ${f.lockouts.map(facilityLabel).join(', ')}`
        : '';
      return `${flow} ${f.carTypes.join('/')} · ${f.baseLoaders} laborer${f.baseLoaders === 1 ? '' : 's'}${lock}`;
    }
    case 'modifier': {
      const m = modifierProfile(k.modifier);
      const adds: string[] = [];
      if (m.addOut) adds.push(`+${m.addOut} out`);
      if (m.addIn) adds.push(`+${m.addIn} in`);
      if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
      if (m.addPorters) adds.push(`+${m.addPorters} porter`);
      return `${adds.join(', ') || 'no change'} · goes beside ${m.hosts.map(facilityLabel).join(' or ')}`;
    }
    default: {
      // The recovered categories carry their own prose — effect plus where it may be played.
      const card = SIMPLE_CARDS.find((c) => c.key === (k as { key: string }).key);
      return card ? `${card.effect} · played on ${card.placement}` : '';
    }
  }
}

/** The train riding a Crew Tray, for anything that has to talk about it. */
export function trainName(s: GameState, trayId: string): string {
  const tray = s.trays.get(trayId);
  if (!tray) return trayId;
  if (tray.trainNumber === null) return 'the local crew';
  return `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
}

/** What a card on the board does, in one short line. */
function cellDescription(card: TrackCard, officeName: string, onRunning: boolean): string {
  const g = card.geometry;
  switch (g.kind) {
    case 'limits':
      return 'the edge of your control area — lay track HERE to extend the Running Track';
    case 'office': {
      const p = officeProfile(
        (OFFICE_ORDER.find((t) => officeProfile(t).name === officeName) ?? 'whistlePost'),
      );
      return (
        `${p.adTracks} A/D track${p.adTracks === 1 ? '' : 's'} — trains stand here to be worked · ` +
        (p.isPassengerFacility
          ? `${p.porters} porter${p.porters === 1 ? '' : 's'}, passengers ${p.passengerOut} out / ${p.passengerIn} in`
          : 'not a Passenger Facility — no porters, no passenger boxes')
      );
    }
    case 'modifier': {
      const m = modifierProfile(g.modifier);
      const adds: string[] = [];
      if (m.addOut) adds.push(`+${m.addOut} outbound slot`);
      if (m.addIn) adds.push(`+${m.addIn} inbound slot`);
      if (m.addLoaders) adds.push(`+${m.addLoaders} laborer`);
      if (m.addPorters) adds.push(`+${m.addPorters} porter`);
      return `${adds.join(', ') || 'no effect'} for the adjacent ${m.hosts.map(facilityLabel).join('/')}`;
    }
    case 'spaceUse':
      return SIMPLE_CARDS.find((c) => c.key === g.key)?.effect ?? 'takes up space';
    case 'facility': {
      const f = card.facility;
      if (!f) return 'a facility';
      if (f.kind === 'passenger') return 'passengers board and detrain here';
      const p = industryProfile(g.facility as never);
      const flow = p.flow === 'both' ? 'ships out AND receives' : p.flow === 'outbound' ? 'ships out' : 'receives';
      // §11.2 — "Facility cards carry their own rails", so an industry on the Running Track does not
      // block anything. It does inherit the Running Track's hazard: §10 makes cars left standing
      // between the Limits and the Office a collision when a train arrives.
      const where = onRunning
        ? ' · ON THE RUNNING TRACK — trains pass straight through, but a car left standing here is hit by the next arrival'
        : '';
      return `${flow} ${p.carTypes.join('/')} · spot a matching car on its siding to work a load${where}`;
    }
    case 'track':
      switch (g.geometry) {
        case 'straight':
          return g.axis === 'ns' ? 'through track, north–south' : 'through track, east–west';
        case 'curved':
        case 'sharpCurved': {
          const stem = g.hand === 'left' ? 'west' : 'east';
          const cost = g.geometry === 'sharpCurved' ? ' · costs TWO Moves to cross' : '';
          return `curve — joins the ${stem} end to a leg running south${cost}`;
        }
        case 'turnout': {
          const t = g.turnout;
          if (!t) return 'turnout';
          const dir = (p: string): string =>
            ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
          // §A.1 is the subtlety worth spelling out: the missing edge, not a one-way street.
          return (
            `turnout — stem ${dir(t.stem)}, through ${dir(t.through)}, diverges ${dir(t.diverge)} · ` +
            `a train may run stem↔through or stem↔diverge, but NEVER between ${dir(t.through)} and ${dir(t.diverge)}`
          );
        }
      }
  }
}

/** An industry's name for the screen. `office` is the Passenger Facility, not an industry. */
function facilityLabel(key: string): string {
  return key === 'office' ? 'the Office' : (FACILITY_NAMES[key] ?? prettyKey(key));
}

/** Every card category that carries a written effect rather than a profile. */
const SIMPLE_CARDS = [
  ...ENHANCEMENT_CARDS,
  ...MAINLINE_MODIFIER_CARDS,
  ...MANEUVER_CARDS,
  ...SPACE_USE_CARDS,
  ...ACTION_CARDS,
];

/** The goal, and whether the current score is keeping up with the clock. */
function objectiveOf(s: GameState): Frame['objective'] {
  const profile = lengthProfile(s.config.length);
  const revenue = s.players[0]?.revenue ?? 0;
  const daysLeft = Math.max(0, profile.days - s.clock.day + 1);
  const elapsed = profile.days - daysLeft + 1;
  // Straight-line pace: by the end of Day N you want N/days of the target.
  const expected = (profile.target * elapsed) / profile.days;
  const onPace = revenue >= expected;
  const note =
    daysLeft === 0
      ? 'the last Day is over'
      : `${revenue} of ${profile.target} · ${daysLeft} Day${daysLeft === 1 ? '' : 's'} left · ` +
        (onPace ? 'on pace' : `behind pace (about ${Math.ceil(expected)} by now)`);
  return { target: profile.target, days: profile.days, daysLeft, onPace, note };
}

/**
 * Which way a piece will point, in words.
 *
 * "rotation 2" is not a choice anyone can make — for a curve or a turnout the orientation IS the
 * decision. Read from `variantsFor`, the same list the placement uses, so the label cannot describe
 * one rotation while the engine lays another.
 */
export function variantLabel(geometry: TrackGeometry, variant: number | undefined): string {
  const v = variantsFor(geometry)[variant ?? 0];
  if (!v) return '';
  if (v.axis) return v.axis === 'ew' ? ' — east–west' : ' — north–south';
  if (v.turnout) {
    const dir = (p: string): string =>
      ({ n: 'north', s: 'south', e: 'east', w: 'west' })[p] ?? p;
    return ` — stem ${dir(v.turnout.stem)}, through ${dir(v.turnout.through)}, diverges ${dir(v.turnout.diverge)}`;
  }
  return '';
}

/**
 * A track shape in words. One place, because it is written on the board, in the action buttons and
 * in the supply list — and `sharpCurved` was reaching the screen raw in two of the three.
 */
export function geometryLabel(geometry: string): string {
  switch (geometry) {
    case 'sharpCurved':
      return 'sharp curve';
    case 'curved':
      return 'curve';
    case 'turnout':
      return 'turnout';
    case 'straight':
      return 'straight';
    default:
      return prettyKey(geometry);
  }
}

/** 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),
  };
}

