/**
 * Plain-English narration for the replay viewer.
 *
 * Two jobs, and the second matters more than it looks:
 *
 *   1. `narrate()` — turn each engine event into a sentence a person can read.
 *   2. `impediments()` — say what is currently BLOCKED and why.
 *
 * Actions are easy to show. Blocked states are what diagnosis actually needs: the open questions
 * are all of the form "why is nothing happening?" — why is a train at the Office only 18% of
 * Stages, why do Laborers sit idle, why does a facility stop working. A log of things that did
 * happen answers none of those.
 *
 * The impediment checks call the engine's own predicates rather than reimplementing them, so the
 * panel cannot drift from the rules.
 */

import { adTrackCount, coordKey } from '../engine/state.ts';
import type { GameState, GridCoord, RollingStock, TrayId } from '../engine/state.ts';
import { canAdvanceLoad, canStartLoad, facilityCarType, laborersLeft, portersLeft } from '../engine/apply.ts';
import type { GameEvent } from '../engine/events.ts';

// ---------------------------------------------------------------------------
// Small formatters
// ---------------------------------------------------------------------------

const CLOCK: readonly string[] = [
  'Midnight', '2:00 AM', '4:00 AM', '6:00 AM', '8:00 AM', '10:00 AM',
  'Noon', '2:00 PM', '4:00 PM', '6:00 PM', '8:00 PM', '10:00 PM',
];

export function clockTime(stage: number): string {
  return CLOCK[stage - 1] ?? `Stage ${stage}`;
}

export function carLabel(c: RollingStock): string {
  // A caboose carries the crew, not freight, so "loaded caboose" is nonsense on the page even
  // though the supply marks every caboose loaded. Name it plainly.
  if (c.type === 'caboose') return 'caboose';
  return `${c.loaded ? 'loaded' : 'empty'} ${c.type}`;
}

export function carsLabel(cars: RollingStock[]): string {
  if (cars.length === 0) return 'nothing';
  return cars.map(carLabel).join(', ');
}

const at = (c: GridCoord): string => `(${c.row},${c.col})`;

const BOX_NAMES = ['MEN', 'AT', 'WORK'] as const;
const boxName = (i: number): string => BOX_NAMES[i] ?? `box ${i}`;

export function phaseLabel(phase: string): string {
  switch (phase) {
    case 'localOps':
      return 'Local Operations';
    case 'newTrain':
      return 'New Train';
    case 'mainline':
      return 'Mainline';
    case 'loadUnload':
      return 'Load / Unload';
    case 'shiftChange':
      return 'Shift Change';
    default:
      return phase;
  }
}

// ---------------------------------------------------------------------------
// Event narration
// ---------------------------------------------------------------------------

export type Narration = {
  text: string;
  /** Colours the line in the viewer. */
  tone: 'plain' | 'good' | 'bad' | 'clock' | 'quiet';
  /** Board cell to highlight, if the event happened somewhere. */
  where?: GridCoord;
};

/**
 * Every member of `GameEvent` must produce a specific sentence. A test asserts the fallback is
 * never reached, so adding an event type without narrating it fails the build rather than quietly
 * degrading the replay.
 */
export type NarrateContext = {
  /** Resolves a card id to something a person can read, e.g. "Mine Tipple" or "Train 8". */
  cardName?: (id: string) => string;
  /**
   * Resolves a Crew Tray id to the train riding it. Without this the §8.1 clearance question read
   * "may tray2 follow tray3 into the next Subdivision?" — the most consequential decision in the
   * game, phrased in internal identifiers.
   */
  trainName?: (trayId: TrayId) => string;
};

export function narrate(e: GameEvent, ctx: NarrateContext = {}): Narration {
  const card = (id: string): string => ctx.cardName?.(id) ?? 'a card';
  const train = (id: TrayId): string => ctx.trainName?.(id) ?? String(id);

  switch (e.type) {
    // -- clock
    case 'stageBegan':
      return { tone: 'clock', text: `── Day ${e.day}, Stage ${e.stage} — ${clockTime(e.stage)} ──` };
    case 'phaseBegan':
      return { tone: 'quiet', text: `${phaseLabel(e.phase)}` };
    case 'actorChanged':
      return {
        tone: 'quiet',
        text: e.player === null ? 'No player acts — automatic phase' : `Player ${e.player} to act`,
      };

    // -- local operations
    case 'localOpsOptionChosen':
      return {
        tone: 'plain',
        text:
          e.option === 'switch'
            ? 'Chose to SWITCH — six Moves to shunt cars around the yard. Watch the crew chip on the grid: it carries its consist with it, and cars it passes over are coupled automatically.'
            : e.option === 'draw'
              ? 'Chose to DRAW a card'
              : 'Chose FREIGHT AGENT work — one car moved to or from a facility',
      };
    case 'trayMoved':
      return {
        tone: 'plain',
        where: e.to,
        text: `CREW moved ${at(e.from)} → ${at(e.to)} — ${e.movesRemaining} of 6 Moves left. The crew chip on the grid carries the whole train with it.`,
      };
    case 'carsCoupled':
      return {
        tone: 'plain',
        where: e.at,
        text: `Coupled ${e.stock.length} car(s) at ${at(e.at)}: ${carsLabel(e.stock)}`,
      };
    case 'consistSorted':
      return {
        tone: 'good',
        where: e.at,
        text: `SMALL YARD — consist re-ordered from [${carsLabel(e.before)}] to [${carsLabel(e.after)}], so the right car is now on the end and can be spotted`,
      };
    case 'carsDropped':
      return {
        tone: 'plain',
        where: e.at,
        text: `Dropped ${carsLabel(e.stock)} at ${at(e.at)}`,
      };

    // -- cards
    case 'cardDrawn':
      return {
        tone: 'plain',
        text:
          e.source === 'homeOffice'
            ? `Drew ${card(e.cardId)} from the Home Office deck`
            : `Took ${card(e.cardId)} from Department slot ${(e.slot ?? 0) + 1}`,
      };
    case 'cardPlayed':
      return {
        tone: 'plain',
        ...(e.placement ? { where: e.placement } : {}),
        text: e.placement
          ? `Played ${card(e.cardId)} onto ${at(e.placement)}`
          : `Played ${card(e.cardId)}`,
      };
    case 'mainlineModified':
      return {
        tone: 'plain',
        text: e.became
          ? `Realignment: Mainline card ${e.node} converted to ${e.became}`
          : `Played ${e.key} on Mainline card ${e.node}`,
      };
    case 'redFlagsSet':
      return {
        tone: 'good',
        text: `Red Flags set out to protect train ${e.trayId} on Mainline card ${e.node} — an approaching train must stop`,
      };
    case 'flyingSwitch':
      return {
        tone: 'good',
        where: e.to,
        text: `Flying Switch — ${e.stock.length} car(s) cut loose and rolled into the industry at ${at(e.to)} without the engine entering`,
      };
    case 'trackLaid':
      return {
        tone: 'plain',
        where: e.at,
        text: `Laid ${e.hand === 'none' ? '' : e.hand + '-hand '}${e.geometry} track at ${at(e.at)} — ${e.remaining} left in the supply`,
      };
    case 'officeUpgraded':
      return { tone: 'good', text: `OFFICE UPGRADED — ${e.from} → ${e.to}` };
    case 'cardDiscarded':
      return {
        tone: 'quiet',
        text: `Discarded ${card(e.cardId)} face-up to Department slot ${e.toSlot + 1}`,
      };
    case 'deckReshuffled':
      return { tone: 'quiet', text: 'Home Office deck ran out — Salvage Yard reshuffled back in' };
    case 'departmentRefilled':
      return {
        tone: 'quiet',
        text: `Department slot ${e.slot + 1} refilled with ${card(e.cardId)}`,
      };

    // -- train lifecycle
    case 'extraQueued':
      return {
        tone: 'good',
        text: `Extra X${e.trainNumber} played — it is NOT scheduled; it runs once as soon as a Crew Tray frees up, then its card is gone`,
      };
    case 'enhancementPlaced':
      return {
        tone: 'good',
        where: e.at,
        text: `ENHANCEMENT built: ${e.key.replace(/([A-Z])/g, ' $1')} at ${at(e.at)}`,
      };
    case 'secondSectionOrdered':
      return {
        tone: 'bad',
        text: `SECOND SECTION ordered on Train ${e.trainNumber} — an identical train will run right behind it, which forces the Superintendent to rule on a following train (§8.1)`,
      };
    case 'trainMadeUp':
      return {
        tone: 'good',
        text: `${e.isExtra ? `EXTRA X${e.trainNumber}` : `TRAIN ${e.trainNumber}`} MADE UP at the ${e.at}, running ${e.direction} — crew assigned, now taking cars`,
      };
    case 'trainHeld':
      return {
        tone: 'bad',
        text: `Train ${e.trainNumber} was due out but is HELD — ${e.reason}`,
      };
    case 'trainHighballed':
      return {
        tone: 'plain',
        text: `Train ${e.trainNumber} HIGHBALLED — departed ${e.from} onto ${e.to}`,
      };
    case 'trainArrived':
      return {
        tone: 'plain',
        text: `Train ${e.trainNumber} ARRIVED at the ${e.office} carrying ${carsLabel(e.consist)} — it will highball again next Mainline Phase, so any work must happen now`,
      };
    case 'trainDiverted':
      return {
        tone: 'good',
        text: `Train ${e.trainNumber} DIVERTED to ${e.to} — ${e.reason}`,
      };
    case 'trainCompleted':
      return {
        tone: 'quiet',
        text:
          `Train ${e.trainNumber} finished its run and left the Division carrying ` +
          `${carsLabel(e.consist)} — the crew is free again`,
      };

    // -- freight agent
    case 'stockToOutbound':
      return {
        tone: 'plain',
        where: e.at,
        text: `Freight Agent put a ${carLabel(e.stock)} into the green Outbound box at ${at(e.at)}`,
      };
    case 'inboundCleared':
      return {
        tone: 'plain',
        where: e.at,
        text: `Freight Agent cleared a ${carLabel(e.stock)} from the red Inbound box at ${at(e.at)}`,
      };
    case 'facilityUnjammed':
      return {
        tone: 'bad',
        where: e.at,
        text: `UNJAMMED ${at(e.at)} — pulled a ${carLabel(e.stock)} out of ${e.from} to free the facility`,
      };

    // -- trains
    case 'trainScheduled': {
      // §7 — roll 1D12 for the slot; if it is taken, work down the column. Reporting only the roll
      // makes a bumped train look like an arithmetic error.
      const bumped = e.slot + 1 !== e.roll;
      return {
        tone: 'good',
        text: bumped
          ? `Train ${e.trainNumber} SCHEDULED at Stage ${e.slot + 1} — rolled ${e.roll}, but Stage ${e.roll} was already taken, so it moved down the column to the next free Stage (§7)`
          : `Train ${e.trainNumber} SCHEDULED to depart at Stage ${e.slot + 1} (rolled ${e.roll}) — it will run at this time EVERY Day from now on`,
      };
    }
    case 'carPlacedOnTrain':
      return { tone: 'plain', text: `Added a ${carLabel(e.stock)} to the train being made up` };
    case 'carPassed':
      return { tone: 'quiet', text: 'Passed — no suitable car in the Division Yard' };
    case 'dispatchBonusUsed':
      return {
        tone: 'good',
        text: `${e.key.toUpperCase()} used (+${e.bonus}) — Train ${e.trainNumber} wins the meet against Train ${e.againstTrain}, which now counts as number ${e.againstTrain + e.bonus}. Once a Day only.`,
      };
    case 'trainsDestroyed': {
      // Say what hit what, where, and what was written off. "COLLISION: NO FREE A/D TRACK" told a
      // player the score had changed and nothing else.
      const why =
        e.reason === 'no free A/D track'
          ? `it arrived at ${e.where} with every A/D track already occupied — there was nowhere to put it (§8.3)`
          : e.reason === 'cars fouling the Running Track'
            ? `it ran into cars left standing on ${e.where} between the Limits and the Office (§8.3)`
            : e.reason;
      const wrecked = e.trains
        .map((t) => `${t.label} (${t.consist.length ? carsLabel(t.consist) : 'no cars'})`)
        .join(' and ');
      return {
        tone: 'bad',
        text:
          `COLLISION — ${wrecked} destroyed: ${why}. Engines and cabooses go back to the Division ` +
          `Yard, all other cars to the Classification Yard (§10). A Timetabled train card returns ` +
          `to its slot and runs again next Day; an Extra is gone for good.`,
      };
    }

    case 'clearanceRequested':
      return {
        tone: 'bad',
        text:
          `SUPERINTENDENT MUST RULE (§8.1): ${train(e.trainId)} wants to enter the Mainline card ` +
          `that ${train(e.occupiedBy)} is still crossing. Allow it and ${train(e.trainId)} may run ` +
          `into the back of ${train(e.occupiedBy)} — a collision costs 5 Revenue. Hold it and it ` +
          `waits where it is, losing time but safe.`,
      };
    case 'clearanceGiven':
      return {
        tone: e.allow ? 'bad' : 'plain',
        text: e.allow
          ? `Clearance GRANTED — ${train(e.trainId)} follows into the occupied Subdivision`
          : `Clearance DENIED — ${train(e.trainId)} holds where it is`,
      };

    // -- passengers
    // §9.2 — "One Porter will allow you to do any ONE of the following actions", so a Depot with a
    // single Porter works exactly one coach per Stage. That is a limit, not a bug.
    case 'passengersBoarded':
      return {
        tone: 'good',
        where: e.at,
        text: `Porter boarded passengers at ${at(e.at)} — one coach per Porter per Stage (§9.2)`,
      };
    case 'passengersDetrained':
      return {
        tone: 'good',
        where: e.at,
        text: `Porter de-trained passengers at ${at(e.at)} — one coach per Porter per Stage (§9.2)`,
      };

    // -- freight pipeline
    case 'loadStarted':
      return {
        tone: 'plain',
        where: e.at,
        text: `Laborer moved a ${e.carType} load from the green box onto MEN`,
      };
    case 'loadAdvanced':
      return {
        tone: 'plain',
        where: e.at,
        text: `Laborer advanced the load ${boxName(e.fromBox)} → ${boxName(e.toBox)}`,
      };
    case 'loadCompleted':
      return {
        tone: 'good',
        where: e.at,
        text: `LOAD FINISHED — ${e.carType} loaded onto the spotted car`,
      };
    case 'unloadBegan':
      return {
        tone: 'plain',
        where: e.at,
        text: `Laborer began unloading a ${e.carType} — load lifted onto WORK`,
      };
    case 'unloadCompleted':
      return {
        tone: 'good',
        where: e.at,
        text: `UNLOAD FINISHED — ${e.carType} delivered into the red Inbound box`,
      };

    // -- consequences
    case 'revenueChanged':
      return e.delta < 0
        ? { tone: 'bad', text: `${e.reason.toUpperCase()} · ${e.delta} Revenue (now ${e.total})` }
        : { tone: 'good', text: `+${e.delta} Revenue (now ${e.total}) — ${e.reason}` };
    case 'phaseEnded':
      return { tone: 'quiet', text: `Player ${e.player} finished ${phaseLabel(e.phase)}` };
  }
}

/** Events that change nothing a viewer can see. Skipped when capturing frames. */
export function isVisible(e: GameEvent): boolean {
  return e.type !== 'actorChanged';
}

/**
 * A phase that ended with nothing done needs saying so explicitly. "Player 0 finished Load/Unload"
 * with no preceding action reads as a gap in the replay when it is actually the game reporting that
 * there was no work available.
 */
export function idleNote(phase: string): string {
  switch (phase) {
    case 'loadUnload':
      return 'Nothing to do this Load/Unload — no load ready to advance, and no train at the platform with passengers to work.';
    case 'localOps':
      return 'Nothing useful to do this Local Operations.';
    default:
      return `Nothing to do this ${phaseLabel(phase)}.`;
  }
}

// ---------------------------------------------------------------------------
// Impediments — "why is nothing happening?"
// ---------------------------------------------------------------------------

export type Impediment = { where: string; why: string; severity: 'stuck' | 'waiting' | 'risk' };

/**
 * Everything currently preventing progress. Derived live from engine predicates, never cached.
 *
 * This is the panel that should answer the standing questions: whether facilities jam, whether
 * trains are held for want of a crew, whether the Office is about to cause a collision.
 */
export function impediments(s: GameState, player = 0): Impediment[] {
  const out: Impediment[] = [];
  const area = s.officeAreas.get(player);
  if (!area) return out;

  for (const [key, card] of area.grid) {
    const f = card.facility;
    if (!f || f.kind !== 'freight') continue;
    const name = card.geometry.kind === 'facility' ? card.geometry.facility : 'facility';
    const want = facilityCarType(f);

    // A load that cannot move, with Laborers standing by, is the worst state a facility reaches:
    // it also strips the industry track of Operational Rail status (§9.3), so no car can be
    // brought in to rescue it.
    for (let box = 0; box < f.menAtWork.length; box++) {
      const load = f.menAtWork[box];
      if (!load || canAdvanceLoad(f, box)) continue;
      const reason =
        laborersLeft(f) < 1
          ? 'all Laborers already used this Stage'
          : load.dir === 'out'
            ? `no empty ${want} spotted to load onto`
            : 'red Inbound box is full';
      out.push({
        where: `${name} ${key}`,
        why: `load STUCK on ${boxName(box)} — ${reason}`,
        severity: laborersLeft(f) < 1 ? 'waiting' : 'stuck',
      });
    }

    if (f.outboundBox.length > 0 && !canStartLoad(f) && laborersLeft(f) > 0) {
      out.push({
        where: `${name} ${key}`,
        why: 'green box has a load but MEN is occupied',
        severity: 'waiting',
      });
    }

    if (f.allows.outbound && f.outboundBox.length === 0) {
      out.push({
        where: `${name} ${key}`,
        why: 'green box empty — nothing to load (needs a Freight Agent action)',
        severity: 'waiting',
      });
    }

    if (f.industryTrack.cars.length >= f.industryTrack.length) {
      out.push({
        where: `${name} ${key}`,
        why: `industry track full (${f.industryTrack.length} cars) — no room to spot another`,
        severity: 'stuck',
      });
    }
  }

  // Trains held for want of a Crew Tray (§7) — the scarcity mechanic, made visible.
  const due = s.timetable[s.clock.stage - 1];
  if (due !== null && due !== undefined && s.freeTrays.length === 0) {
    out.push({
      where: `Train ${due}`,
      why: 'due to depart but HELD — no free Crew Tray',
      severity: 'stuck',
    });
  }

  // A full Office means the next arrival is an automatic collision (Gap 2d).
  const cap = adTrackCount(s, player);
  if (area.adOccupancy.length >= cap) {
    out.push({
      where: 'Office',
      why: `all ${cap} A/D track(s) occupied — the next arrival COLLIDES`,
      severity: 'risk',
    });
  }

  if (s.clock.pendingDecision) {
    out.push({
      where: 'Superintendent',
      why: 'must rule on a following-train clearance before the Mainline Phase continues',
      severity: 'waiting',
    });
  }

  return out;
}

/** A one-line summary of where every train currently is. */
export function trainPositions(s: GameState): { id: TrayId; label: string; where: string }[] {
  const out: { id: TrayId; label: string; where: string }[] = [];
  for (const [id, tray] of s.trays) {
    const label = tray.trainNumber === null ? 'local crew' : `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`;
    let where: string;
    switch (tray.position.at) {
      case 'divisionPoint':
        where = `${tray.position.side === 'west' ? 'West' : 'East'} Division Point`;
        break;
      case 'mainline':
        where = `Mainline card ${tray.position.index}`;
        break;
      case 'grid':
        where = `Office Area ${at(tray.position.coord)}`;
        break;
    }
    out.push({ id, label, where });
  }
  return out;
}

export { coordKey };
