/**
 * Component 4 — Intent validation and application.
 *
 * `applyIntent(state, actor, intent) -> events | rejection`.
 * See architecture/components.md §2 A.4.
 *
 * STRUCTURE. Every intent is a `check` + `execute` pair:
 *   - `check` answers "is this legal right now?" and NEVER mutates.
 *   - `execute` reads state and emits events; it never mutates either.
 *   - `reduce` is the only thing that mutates, folding events into state.
 *
 * That keeps `state = fold(events)` true by construction, which is what makes replay and restart
 * recovery work. It also lets component 6 (legalActions) call these very same `check` functions,
 * so the two can never drift apart — see legal.ts.
 */

import {
  FREIGHT_PROFILES,
  HAND_LIMIT,
  LABORER_ACTIONS_PER_LOAD,
  MAX_CONSIST,
  REALIGNMENTS,
  enhancementRule,
  industryProfile,
  mainlineModifierRule,
  mainlineProfile,
  modifierProfile,
  nextOfficeTier,
  officeProfile,
  trainProfile,
} from './content.ts';
import type { CarType, FreightKind, Hand, MainlineKind, ModifierKind, TrackGeometry } from './content.ts';
import type { GameEvent } from './events.ts';
import type { Intent, RejectionCode } from './intents.ts';
import type {
  CardId,
  CrewTray,
  Facility,
  GameState,
  GridCoord,
  OfficeArea,
  PlayerIndex,
  RollingStock,
  TrackCard,
  TrayId,
} from './state.ts';
import { createRng } from './rng.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';
import type { Occupancy, Port } from './track.ts';
import {
  canDropCarsAt,
  canPlaceAt,
  facilityVariants,
  opposite,
  reachableDestinations,
  variantsFor,
} from './track.ts';

export type ApplyResult =
  | { ok: true; events: GameEvent[] }
  | { ok: false; code: RejectionCode; message: string };

// ---------------------------------------------------------------------------
// Lookup helpers
// ---------------------------------------------------------------------------

export function areaOf(s: GameState, player: PlayerIndex): OfficeArea {
  const a = s.officeAreas.get(player);
  if (!a) throw new Error(`no Office Area for player ${player}`);
  return a;
}

function cardAt(area: OfficeArea, c: GridCoord): TrackCard | undefined {
  return area.grid.get(coordKey(c));
}

function facilityAt(s: GameState, player: PlayerIndex, c: GridCoord): Facility | null {
  return cardAt(areaOf(s, player), c)?.facility ?? null;
}

function trayCoord(s: GameState, trayId: TrayId): GridCoord | null {
  const tray = s.trays.get(trayId);
  if (!tray || tray.position.at !== 'grid') return null;
  return tray.position.coord;
}

/** A tray sitting on the Office card occupies an A/D track (§2.1). */
function occupancyFor(s: GameState, player: PlayerIndex, self: TrayId): Occupancy {
  const area = areaOf(s, player);
  return {
    trayAt: (c) => {
      for (const [id, tray] of s.trays) {
        if (tray.position.at === 'grid' && tray.position.coord.row === c.row && tray.position.coord.col === c.col) {
          return id;
        }
      }
      return null;
    },
    freeAdTracks: () => {
      const cap = officeProfile(area.tier).adTracks;
      return cap - area.adOccupancy.filter((t) => t !== self).length;
    },
  };
}

/** A tray's facing, expressed as the port it would leave by going forward. */
function facingPort(s: GameState, trayId: TrayId): Port {
  const tray = s.trays.get(trayId);
  if (tray?.facing) return tray.facing;
  return tray?.direction === 'west' ? 'w' : 'e';
}

// ---------------------------------------------------------------------------
// Shared predicates — used by BOTH check() below and legalActions()
// ---------------------------------------------------------------------------

export function isActor(s: GameState, player: PlayerIndex): boolean {
  return s.clock.currentActor === player;
}

export function inPhase(s: GameState, phase: GameState['clock']['phase']): boolean {
  return s.clock.phase === phase;
}

/**
 * §6 — "a player may do one of three things". You cannot do a thing that does not exist: a player
 * with no Facility has no Freight Agent operation available, and one with no Crew Tray in his area
 * has nothing to switch. Choosing such an option is illegal rather than a wasted turn.
 *
 * These deliberately inspect state directly rather than calling `check`, which would be circular.
 */
export function hasFreightAgentOption(s: GameState, player: PlayerIndex): boolean {
  for (const card of areaOf(s, player).grid.values()) {
    const f = card.facility;
    if (!f) continue;
    if (f.allows.outbound && f.outboundBox.length < f.capacity.outbound) return true;
    if (f.inboundBox.length > 0) return true;
    if (f.menAtWork.some((l) => l !== null)) return true;
    if (f.outboundBox.length > 0) return true;
  }
  return false;
}

/**
 * §9.1 — "Loading boxes are green, with the icon of the car type that can be loaded from there."
 * A facility handles exactly one commodity, so only that car type may be stocked into it.
 *
 * Without this check a Mine Tipple could be stocked with a coach. The load would then wait forever
 * for an empty coach to be spotted on a hopper siding, and because a load parked on MEN|AT|WORK
 * strips the industry track of Operational Rail status (§9.3), the facility would jam permanently.
 */
export function facilityCarType(f: Facility): CarType | null {
  if (f.kind !== 'freight') return f.kind === 'passenger' ? 'coach' : null;
  return FREIGHT_PROFILES.find((p) => p.kind === f.subtype)?.carTypes[0] ?? null;
}

export function hasSwitchOption(s: GameState, player: PlayerIndex): boolean {
  for (const tray of s.trays.values()) {
    if (tray.position.at === 'grid' && tray.position.owner === player) return true;
  }
  return false;
}

/** §9.1 — Laborers and Porters may be used once each per Stage. */
export function laborersLeft(f: Facility): number {
  return f.laborers - f.usedThisStage.laborers;
}

export function portersLeft(f: Facility): number {
  return f.porters - f.usedThisStage.porters;
}

/**
 * Where a load may advance to along MEN | AT | WORK (§9.3).
 *
 * Direction depends on which way the load is travelling. **Outbound** runs Green -> MEN -> AT ->
 * WORK -> onto a spotted empty car. **Inbound** runs car -> WORK -> AT -> MEN -> red box. Getting
 * this wrong conflates loading with unloading, which is exactly the bug the statistics found:
 * `loadAdvanced` never fired in 200 games because the outbound pipeline had no entry point.
 */
export function canAdvanceLoad(f: Facility, box: number): boolean {
  if (box < 0 || box >= f.menAtWork.length) return false;
  const load = f.menAtWork[box];
  if (!load) return false;
  if (laborersLeft(f) < 1) return false;

  const next = load.dir === 'out' ? box + 1 : box - 1;

  if (next >= f.menAtWork.length) {
    // Off WORK and onto a spotted empty car of the right type.
    return f.industryTrack.cars.some((c) => !c.loaded && c.type === load.type);
  }
  if (next < 0) {
    // Off MEN and into the red Inbound box.
    return f.inboundBox.length < f.capacity.inbound;
  }
  return f.menAtWork[next] === null;
}

/** §9.3 — the first Laborer step of an outbound load: Green Loading Slot onto MEN. */
export function canStartLoad(f: Facility): boolean {
  if (laborersLeft(f) < 1) return false;
  if (f.outboundBox.length === 0) return false;
  return f.menAtWork[0] === null;
}

/** §9.2 — boarding needs a loaded coach in a green slot and a train with an empty coach. */
export function canBoard(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
  const f = facilityAt(s, player, at);
  if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
  if (!f.outboundBox.some((c) => c.type === 'coach' && c.loaded)) return false;
  return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && !c.loaded);
}

/** §9.2 — de-training needs an open red slot and a train carrying a loaded coach. */
export function canDetrain(s: GameState, player: PlayerIndex, at: GridCoord): boolean {
  const f = facilityAt(s, player, at);
  if (!f || f.kind !== 'passenger' || portersLeft(f) < 1) return false;
  if (f.inboundBox.length >= f.capacity.inbound) return false;
  return trainAtOfficeWith(s, player, (c) => c.type === 'coach' && c.loaded);
}

function trainAtOfficeWith(
  s: GameState,
  player: PlayerIndex,
  pred: (c: RollingStock) => boolean,
): boolean {
  const area = areaOf(s, player);
  return area.adOccupancy.some((id) => s.trays.get(id)?.consist.some(pred) ?? false);
}

// ---------------------------------------------------------------------------
// check — never mutates
// ---------------------------------------------------------------------------

export function check(s: GameState, player: PlayerIndex, i: Intent): RejectionCode | null {
  if (s.status !== 'active') return 'WRONG_PHASE';

  // The clearance ruling is the one intent that arrives out of turn order: it interrupts the
  // automatic Mainline Phase and goes to the Superintendent (§8.1, fourth condition).
  if (i.type === 'mainline.clearance') {
    if (s.clock.pendingDecision === null) return 'NO_PENDING_DECISION';
    if (s.clock.superintendent !== player) return 'NOT_SUPERINTENDENT';
    return null;
  }

  if (!isActor(s, player)) return 'NOT_YOUR_TURN';

  switch (i.type) {
    // -- Local Operations -----------------------------------------------------
    case 'localOps.choose':
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== null) return 'OPTION_ALREADY_CHOSEN';
      // An option with no possible follow-up is not available at all (§6).
      if (i.option === 'freightAgent' && !hasFreightAgentOption(s, player)) return 'NO_SUCH_FACILITY';
      if (i.option === 'switch' && !hasSwitchOption(s, player)) return 'NO_SUCH_TRAY';
      return null;

    case 'switch.move': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
      if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
      const tray = s.trays.get(i.trayId);
      if (!tray) return 'NO_SUCH_TRAY';
      const from = trayCoord(s, i.trayId);
      if (!from) return 'ILLEGAL_MOVE';
      const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
      return dests.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)
        ? null
        : 'ILLEGAL_MOVE';
    }

    case 'switch.dropCars': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
      const tray = s.trays.get(i.trayId);
      if (!tray) return 'NO_SUCH_TRAY';
      if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
      const here = trayCoord(s, i.trayId);
      if (!here) return 'CANNOT_DROP_HERE';
      return canDropCarsAt(areaOf(s, player), here, i.count) ? null : 'CANNOT_DROP_HERE';
    }

    case 'switch.sortConsist': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
      if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
      const tray = s.trays.get(i.trayId);
      if (!tray) return 'NO_SUCH_TRAY';
      const here = trayCoord(s, i.trayId);
      if (!here) return 'ILLEGAL_MOVE';
      // Only on a card carrying a Small Yard, and it costs the Move it "spends in the yard".
      const card = areaOf(s, player).grid.get(coordKey(here));
      if (!card?.enhancements.includes('smallYard')) return 'NOT_CONNECTED';
      // The order must be a permutation of the current consist.
      if (i.order.length !== tray.consist.length) return 'CONSIST_ORDER';
      const seen = new Set(i.order);
      if (seen.size !== i.order.length) return 'CONSIST_ORDER';
      if (i.order.some((n) => n < 0 || n >= tray.consist.length)) return 'CONSIST_ORDER';
      return null;
    }

    case 'switch.end':
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      return s.turn.option === 'switch' ? null : 'OPTION_NOT_CHOSEN';

    // -- Draw a card ----------------------------------------------------------
    case 'draw.fromHomeOffice':
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
      if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
      if (s.decks.homeOffice.length === 0) return 'DECK_EMPTY';
      return null;

    case 'draw.fromDepartment':
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
      if (s.turn.drawnThisTurn) return 'OPTION_ALREADY_CHOSEN';
      if (i.slot < 0 || i.slot > 2) return 'SLOT_EMPTY';
      return s.decks.departments[i.slot] ? null : 'SLOT_EMPTY';

    case 'card.play': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
      const hand = s.decks.hands.get(player) ?? [];
      if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
      return checkPlay(s, player, i.cardId, i.placement, i.variant);
    }

    case 'card.discard': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      const hand = s.decks.hands.get(player) ?? [];
      if (!hand.includes(i.cardId)) return 'CARD_NOT_IN_HAND';
      if (i.toSlot < 0 || i.toSlot > 2) return 'SLOT_EMPTY';
      return null;
    }

    case 'track.lay': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
      if (s.turn.laidThisTurn) return 'OPTION_ALREADY_CHOSEN';
      const key = `${i.geometry}:${i.hand}`;
      if ((areaOf(s, player).trackSupply.get(key) ?? 0) < 1) return 'NO_SUCH_CARD';
      const proto = protoTrackCard(i.geometry, i.hand, i.variant);
      if (!proto) return 'NO_PLACEMENT';
      return canPlaceAt(areaOf(s, player), i.placement, proto) ? null : 'NOT_CONNECTED';
    }

    case 'mainline.modify': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
      const card = s.cards.get(i.cardId);
      if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
      if (card.kind.kind !== 'mainlineModifier') return 'WRONG_INTENT';
      const rule = mainlineModifierRule(card.kind.key);
      if (!rule) return 'NOT_IMPLEMENTED';
      const node = s.division.nodes[i.node];
      if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
      const on = node.modifiers ?? [];
      if (on.includes(rule.key)) return 'OPTION_ALREADY_CHOSEN';
      if (rule.gradeOnly && mainlineProfile(node.card).speed.kind !== 'grade') return 'NOT_A_GRADE';
      if (rule.requiresOnCard && !on.includes(rule.requiresOnCard)) return 'NOT_CONNECTED';
      // "Not while a train is on it" — realigning under a moving train is exactly the situation the
      // restriction exists to prevent.
      if (node.transits.length > 0) return 'TRAIN_ON_CARD';
      if (rule.key === 'realignment' && !REALIGNMENTS.some((r) => r.from === node.card)) {
        return 'NO_PLACEMENT';
      }
      return null;
    }

    case 'maneuver.redFlags': {
      const card = s.cards.get(i.cardId);
      if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
      if (card.kind.kind !== 'maneuver' || card.kind.key !== 'redFlags') return 'WRONG_INTENT';
      const tray = s.trays.get(i.trayId);
      if (!tray) return 'NO_SUCH_TRAY';
      // "A STOPPED train is prevented from being hit" — it protects a train that is standing on a
      // Mainline card, which is the only place a rear-ender can happen.
      if (tray.position.at !== 'mainline') return 'NO_PLACEMENT';
      const node = s.division.nodes[tray.position.index];
      if (!node || node.kind !== 'mainline') return 'NO_PLACEMENT';
      if ((node.redFlagged ?? []).includes(i.trayId)) return 'OPTION_ALREADY_CHOSEN';
      return null;
    }

    case 'maneuver.flyingSwitch': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'switch') return 'OPTION_NOT_CHOSEN';
      if (s.turn.movesRemaining < 1) return 'NO_MOVES_REMAINING';
      const card = s.cards.get(i.cardId);
      if (!card || !(s.decks.hands.get(player) ?? []).includes(i.cardId)) return 'NO_SUCH_CARD';
      if (card.kind.kind !== 'maneuver' || card.kind.key !== 'flyingSwitch') return 'WRONG_INTENT';
      const tray = s.trays.get(i.trayId);
      if (!tray) return 'NO_SUCH_TRAY';
      const here = trayCoord(s, i.trayId);
      if (!here) return 'CANNOT_DROP_HERE';
      if (i.count < 1 || i.count > tray.consist.length) return 'CONSIST_EMPTY';
      // The cut is uncoupled and ROLLS to the industry under its own momentum, so the target must
      // be somewhere the train could itself have run to — track-connected, not merely a neighbouring
      // square. An earlier version tested orthogonal adjacency, which was wrong in both directions:
      // it would have allowed a cut to cross to a cell with no rail between, while refusing a siding
      // two cards along the same track. It also made the card effectively unplayable — held for
      // 1,400 turns across 60 games and legal on 2.
      const reachable = [
        ...destinationsFor(s, player, i.trayId, here, false),
        ...destinationsFor(s, player, i.trayId, here, true),
      ];
      if (!reachable.some((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)) {
        return 'NOT_CONNECTED';
      }
      const fsArea = areaOf(s, player);
      const target = fsArea.grid.get(coordKey(i.to));
      if (!target?.facility || target.facility.kind !== 'freight') return 'CANNOT_DROP_HERE';
      return canDropCarsAt(fsArea, i.to, i.count) ? null : 'CANNOT_DROP_HERE';
    }

    case 'draw.end': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'draw') return 'OPTION_NOT_CHOSEN';
      // §6.2 — "the player must reduce his hand to no more than three cards".
      const hand = s.decks.hands.get(player) ?? [];
      const limit = s.decks.redFlags.get(player) ? HAND_LIMIT + 1 : HAND_LIMIT;
      return hand.length > limit ? 'HAND_LIMIT' : null;
    }

    // -- Freight Agent --------------------------------------------------------
    case 'freightAgent.stockOutbound': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
      if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
      const f = facilityAt(s, player, i.at);
      if (!f) return 'NO_SUCH_FACILITY';
      if (!f.allows.outbound) return 'NO_SUCH_FACILITY';
      if (f.outboundBox.length >= f.capacity.outbound) return 'BOX_FULL';
      // §9.1 — the green box takes only this facility's commodity.
      if (facilityCarType(f) !== i.carType) return 'WRONG_CAR_TYPE';
      if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded)) {
        return 'NO_SUITABLE_CAR';
      }
      return null;
    }

    case 'freightAgent.clearInbound': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
      if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
      const f = facilityAt(s, player, i.at);
      if (!f) return 'NO_SUCH_FACILITY';
      return f.inboundBox[i.index] ? null : 'BOX_EMPTY';
    }

    case 'freightAgent.unjam': {
      if (!inPhase(s, 'localOps')) return 'WRONG_PHASE';
      if (s.turn.option !== 'freightAgent') return 'OPTION_NOT_CHOSEN';
      if (s.turn.freightAgentUsed) return 'OPTION_ALREADY_CHOSEN';
      const f = facilityAt(s, player, i.at);
      if (!f) return 'NO_SUCH_FACILITY';
      if (i.from === 'menAtWork') return f.menAtWork[i.index] ? null : 'BOX_EMPTY';
      const box = i.from === 'outbound' ? f.outboundBox : f.inboundBox;
      return box[i.index] ? null : 'BOX_EMPTY';
    }

    // -- New Train ------------------------------------------------------------
    case 'newTrain.placeCar': {
      if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
      const tray = s.trays.get(i.trayId);
      if (!tray) return 'NO_SUCH_TRAY';
      if (tray.consist.length >= MAX_CONSIST) return 'CONSIST_FULL';
      if (!s.yards.divisionYard.some((c) => c.type === i.carType && c.loaded === i.loaded)) {
        return 'NO_SUITABLE_CAR';
      }

      // §8.2 — "the train must be in the order listed on the train's card (engine on the front,
      // Rolling Stock, and possibly a Caboose). It may depart with FEWER Rolling Stock than listed,
      // but not out of order."
      //
      // This was not enforced at all: any car could be added in any quantity, so Train 9 "Heavy
      // Freight" — a card calling for 3 freight AND a caboose — was made up with four hoppers and
      // no caboose. Fewer is allowed; more, or of the wrong category, is not.
      return acceptsCar(tray, i.carType) ? null : 'NO_SUITABLE_CAR';
    }

    case 'newTrain.secondSection': {
      if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
      // Only on a train that is actually due out this Stage — a second section follows a first.
      if (s.timetable[s.clock.stage - 1] !== i.trainNumber) return 'NO_SUCH_TRAY';
      if (s.freeTrays.length === 0) return 'NO_SUCH_TRAY';
      return null;
    }

    case 'newTrain.passCar': {
      if (!inPhase(s, 'newTrain')) return 'WRONG_PHASE';
      const tray = s.trays.get(i.trayId);
      if (!tray) return 'NO_SUCH_TRAY';
      // §7 — "must make every effort to find a suitable car". A pass is only legal when none exists.
      return s.yards.divisionYard.length > 0 ? 'SUITABLE_CAR_EXISTS' : null;
    }

    // -- Load / Unload --------------------------------------------------------
    case 'porter.board':
      if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
      if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
      if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
      return canBoard(s, player, i.at) ? null : 'NO_TRAIN_AT_OFFICE';

    case 'porter.detrain':
      if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
      if (!facilityAt(s, player, i.at)) return 'NO_SUCH_FACILITY';
      if (portersLeft(facilityAt(s, player, i.at)!) < 1) return 'RESOURCE_SPENT';
      return canDetrain(s, player, i.at) ? null : 'NO_TRAIN_AT_OFFICE';

    case 'laborer.startLoad': {
      if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
      const f = facilityAt(s, player, i.at);
      if (!f) return 'NO_SUCH_FACILITY';
      if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
      return canStartLoad(f) ? null : 'BOX_EMPTY';
    }

    case 'laborer.advanceLoad': {
      if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
      const f = facilityAt(s, player, i.at);
      if (!f) return 'NO_SUCH_FACILITY';
      if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
      return canAdvanceLoad(f, i.box) ? null : 'BOX_EMPTY';
    }

    case 'laborer.beginUnload': {
      if (!inPhase(s, 'loadUnload')) return 'WRONG_PHASE';
      const f = facilityAt(s, player, i.at);
      if (!f) return 'NO_SUCH_FACILITY';
      if (!f.allows.inbound) return 'NO_SUCH_FACILITY';
      if (laborersLeft(f) < 1) return 'RESOURCE_SPENT';
      const car = f.industryTrack.cars[i.carIndex];
      if (!car || !car.loaded) return 'WRONG_CAR_TYPE';
      // The load is placed on WORK, the last box, so that box must be free.
      return f.menAtWork[f.menAtWork.length - 1] === null ? null : 'BOX_FULL';
    }

    case 'loadUnload.end':
      return inPhase(s, 'loadUnload') ? null : 'WRONG_PHASE';

    case 'redFlag.play':
      return s.decks.redFlags.get(player) ? null : 'NO_SUCH_CARD';

    default:
      return 'WRONG_PHASE';
  }
}

/** Playing a card from hand (§6.2). Track, Facility and Office cards need a placement. */
function checkPlay(
  s: GameState,
  player: PlayerIndex,
  cardId: string,
  placement: GridCoord | undefined,
  variant: number | undefined,
): RejectionCode | null {
  const card = s.cards.get(cardId);
  if (!card) return 'NO_SUCH_CARD';
  const area = areaOf(s, player);

  switch (card.kind.kind) {
    case 'office': {
      // An upgrade replaces the Office in place (Gap 8), so a placement is meaningless — and
      // accepting one makes the event log claim the card was laid somewhere it was not.
      if (placement) return 'NO_PLACEMENT';
      // Gap 3b — strict sequence, no skipping.
      const next = nextOfficeTier(area.tier);
      return next === card.kind.tier ? null : 'NOT_UPGRADEABLE';
    }
    case 'track':
      if (!placement) return 'NO_PLACEMENT';
      {
        const proto = protoCard(card.kind, variant);
        if (!proto) return 'NO_PLACEMENT';
        return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
      }

    case 'freightFacility': {
      if (!placement) return 'NO_PLACEMENT';
      // Q4 — a lockout prevents BUILDING both in one district. Each locked pair is a producer and
      // the consumer of the same commodity, so this forces traffic to flow between districts.
      if (isLockedOut(area, card.kind.facility)) return 'FACILITY_LOCKED';
      const proto = protoCard(card.kind, variant);
      if (!proto) return 'NO_PLACEMENT';
      return canPlaceAt(area, placement, proto) ? null : 'NOT_CONNECTED';
    }
    case 'modifier': {
      if (!placement) return 'NO_PLACEMENT';
      if (area.grid.has(coordKey(placement))) return 'NOT_CONNECTED';
      // §9 — a Modifier is not track. It must sit adjacent to a Facility (one of the nine nearby
      // spots) or it does nothing at all, so placing it anywhere else is not a legal play.
      return adjacentFacilityCoord(area, placement) ? null : 'NOT_CONNECTED';
    }
    case 'enhancement': {
      if (!placement) return 'NO_PLACEMENT';
      return checkEnhancementPlacement(s, area, card.kind.key, placement);
    }

    case 'mainlineModifier':
      // Facing Point Locks appears in BOTH the Enhancement and Mainline-modifier lists on the sheet,
      // with the same placement and the same effect. It is one card printed twice, so the mainline
      // copy uses the enhancement's grid placement rather than going onto a Mainline card.
      if (card.kind.key === 'facingPointLocksMainline') {
        if (!placement) return 'NO_PLACEMENT';
        return checkEnhancementPlacement(s, area, 'facingPointLocks', placement);
      }
      // The rest are laid on a Mainline card, which is not a grid coordinate — see
      // `mainline.modify`.
      return 'WRONG_INTENT';

    case 'maneuver':
      // Red Flags and Flying Switch have their own intents; Poling's effect is recorded as "TBD in
      // the source", so there is nothing to implement.
      return 'WRONG_INTENT';

    case 'spaceUse':
    case 'action':
      // Recovered from the design but not yet implemented — see docs/rules/implications.md §7 and
      // §10. Rejecting is honest: silently accepting would make the card look playable while doing
      // nothing, which is exactly the bug that made Modifiers dead weight for weeks.
      return 'NOT_IMPLEMENTED';

    case 'timetabledTrain':
    case 'extraTrain':
      // A train card goes to the TIMETABLE, not onto the board. Accepting a placement made the UI
      // offer the same play at six different grid squares with six rotations apiece — all identical,
      // because the placement was then ignored. Same reasoning as the Office upgrade above: silently
      // discarding a placement makes the event log claim the card was laid somewhere it was not.
      return placement ? 'NO_PLACEMENT' : null;

    default:
      return null;
  }
}

function destinationsFor(
  s: GameState,
  player: PlayerIndex,
  trayId: TrayId,
  from: GridCoord,
  reverse: boolean,
) {
  const tray = s.trays.get(trayId)!;
  const facing = facingPort(s, trayId);
  // Reversing is the OPPOSITE port, whichever it is. This was hardcoded to flip between east and
  // west, so a crew facing north or south reversed to 'e' — a port a north-south card does not
  // have — and could never back out of a district spur. Combined with a facing that was itself
  // derived from an east/west direction, it stranded 29 of 62 leftover crews on north-south track.
  const exit: Port = reverse ? opposite(facing) : facing;
  return reachableDestinations(
    {
      area: areaOf(s, player),
      occupancy: occupancyFor(s, player, trayId),
      consistSize: tray.consist.length,
      self: trayId,
    },
    from,
    exit,
  );
}

// ---------------------------------------------------------------------------
// execute — reads state, emits events, never mutates
// ---------------------------------------------------------------------------

function execute(s: GameState, player: PlayerIndex, i: Intent): GameEvent[] {
  switch (i.type) {
    case 'localOps.choose':
      return [{ type: 'localOpsOptionChosen', player, option: i.option }];

    case 'switch.move': {
      const from = trayCoord(s, i.trayId)!;
      const dests = destinationsFor(s, player, i.trayId, from, i.reverse);
      const dest = dests.find((d) => d.coord.row === i.to.row && d.coord.col === i.to.col)!;
      // The crew leaves by the port opposite the one it entered through, which is what it will be
      // facing when it stops. Without this the facing stays 'e'/'w' forever and a crew that turns
      // onto a north-south spur can never move again.
      const events: GameEvent[] = [
        {
          type: 'trayMoved',
          trayId: i.trayId,
          from,
          to: i.to,
          movesRemaining: s.turn.movesRemaining - 1,
          facing: opposite(dest.entry),
        },
      ];
      if (dest.couples.length > 0) {
        events.push({ type: 'carsCoupled', trayId: i.trayId, at: i.to, stock: dest.couples });
      }
      return events;
    }

    case 'switch.dropCars': {
      const tray = s.trays.get(i.trayId)!;
      const here = trayCoord(s, i.trayId)!;
      // §A.3 — cars come off in the order they are seated in the tray.
      const stock = tray.consist.slice(tray.consist.length - i.count);
      return [{ type: 'carsDropped', trayId: i.trayId, at: here, stock }];
    }

    case 'switch.sortConsist': {
      const tray = s.trays.get(i.trayId)!;
      const here = trayCoord(s, i.trayId)!;
      return [
        {
          type: 'consistSorted',
          trayId: i.trayId,
          at: here,
          before: tray.consist.map((c) => ({ ...c })),
          after: i.order.map((n) => ({ ...tray.consist[n]! })),
        },
      ];
    }

    case 'switch.end':
    case 'draw.end':
      return [{ type: 'phaseEnded', player, phase: 'localOps' }];

    case 'draw.fromHomeOffice':
      return [
        {
          type: 'cardDrawn',
          player,
          source: 'homeOffice',
          cardId: s.decks.homeOffice[s.decks.homeOffice.length - 1]!,
        },
      ];

    case 'draw.fromDepartment': {
      const events: GameEvent[] = [
        {
          type: 'cardDrawn',
          player,
          source: 'department',
          slot: i.slot,
          cardId: s.decks.departments[i.slot]!,
        },
      ];
      // §6.2 — "If any of the Department decks is empty, draw a Home Office card and place it in
      // the empty spot." Without this the three face-up slots empty out permanently and the market
      // silently disappears from the game.
      const refill = s.decks.homeOffice[s.decks.homeOffice.length - 1];
      if (refill) {
        events.push({ type: 'departmentRefilled', slot: i.slot, cardId: refill });
      }
      return events;
    }

    case 'card.play': {
      const card = s.cards.get(i.cardId)!;
      const events: GameEvent[] = [
        i.placement
          ? {
              type: 'cardPlayed',
              player,
              cardId: i.cardId,
              placement: i.placement,
              variant: i.variant ?? 0,
            }
          : { type: 'cardPlayed', player, cardId: i.cardId },
      ];
      if (card.kind.kind === 'office') {
        events.push({
          type: 'officeUpgraded',
          player,
          from: areaOf(s, player).tier,
          to: card.kind.tier,
        });
      }
      if (card.kind.kind === 'enhancement' && i.placement) {
        events.push({
          type: 'enhancementPlaced',
          player,
          key: card.kind.key,
          at: i.placement,
        });
      }
      if (card.kind.kind === 'extraTrain') {
        // §7 — an Extra runs once, immediately, as soon as a Crew Tray frees up. Playing one used
        // to do nothing at all, which made all four Extra cards dead weight in the deck.
        events.push({ type: 'extraQueued', player, trainNumber: card.kind.number });
      }
      if (card.kind.kind === 'timetabledTrain') {
        // §7 — roll 1D12 for the timetable slot; if occupied, work down the column, wrapping at
        // the bottom. Without this no train ever runs, so nothing can ever be earned.
        const rng = createRng(s.rngState);
        const roll = rng.d12();
        const slot = findTimetableSlot(s, roll - 1);
        if (slot !== null) {
          events.push({
            type: 'trainScheduled',
            player,
            trainNumber: card.kind.number,
            roll,
            slot,
            rngState: rng.getState(),
          });
        }
      }
      return events;
    }

    case 'card.discard':
      return [{ type: 'cardDiscarded', player, cardId: i.cardId, toSlot: i.toSlot }];

    case 'mainline.modify': {
      const card = s.cards.get(i.cardId)!;
      const key = card.kind.kind === 'mainlineModifier' ? card.kind.key : '';
      const node = s.division.nodes[i.node];
      const became =
        key === 'realignment' && node?.kind === 'mainline'
          ? REALIGNMENTS.find((r) => r.from === node.card)?.to
          : undefined;
      return [
        { type: 'mainlineModified', player, cardId: i.cardId, node: i.node, key, ...(became ? { became } : {}) },
      ];
    }

    case 'maneuver.redFlags': {
      const tray = s.trays.get(i.trayId)!;
      const index = tray.position.at === 'mainline' ? tray.position.index : -1;
      return [{ type: 'redFlagsSet', player, cardId: i.cardId, trayId: i.trayId, node: index }];
    }

    case 'maneuver.flyingSwitch': {
      const tray = s.trays.get(i.trayId)!;
      // §A.3 — cars come off the back, same as a normal drop.
      const stock = tray.consist.slice(tray.consist.length - i.count);
      return [{ type: 'flyingSwitch', player, cardId: i.cardId, trayId: i.trayId, to: i.to, stock }];
    }

    case 'track.lay': {
      const key = `${i.geometry}:${i.hand}`;
      return [
        {
          type: 'trackLaid',
          player,
          geometry: i.geometry,
          hand: i.hand,
          at: i.placement,
          variant: i.variant ?? 0,
          remaining: (areaOf(s, player).trackSupply.get(key) ?? 1) - 1,
        },
      ];
    }

    case 'freightAgent.stockOutbound':
      return [
        { type: 'stockToOutbound', player, at: i.at, stock: { type: i.carType, loaded: true } },
      ];

    case 'freightAgent.clearInbound': {
      const f = facilityAt(s, player, i.at)!;
      return [{ type: 'inboundCleared', player, at: i.at, stock: f.inboundBox[i.index]! }];
    }

    case 'freightAgent.unjam': {
      const f = facilityAt(s, player, i.at)!;
      const stock: RollingStock =
        i.from === 'menAtWork'
          ? { type: f.menAtWork[i.index]!.type, loaded: true }
          : (i.from === 'outbound' ? f.outboundBox : f.inboundBox)[i.index]!;
      return [{ type: 'facilityUnjammed', player, at: i.at, from: i.from, stock }];
    }

    case 'newTrain.placeCar':
      return [
        {
          type: 'carPlacedOnTrain',
          player,
          trayId: i.trayId,
          stock: { type: i.carType, loaded: i.loaded },
        },
      ];

    case 'newTrain.passCar':
      return [{ type: 'carPassed', player, trayId: i.trayId }];

    case 'newTrain.secondSection':
      return [{ type: 'secondSectionOrdered', player, trainNumber: i.trainNumber }];

    case 'mainline.clearance':
      return [
        {
          type: 'clearanceGiven',
          trainId: s.clock.pendingDecision!.train,
          allow: i.allow,
        },
      ];

    case 'porter.board':
      return [
        { type: 'passengersBoarded', player, at: i.at },
        { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'boarding' },
      ];

    case 'porter.detrain':
      return [
        { type: 'passengersDetrained', player, at: i.at },
        { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'detraining' },
      ];

    case 'laborer.startLoad': {
      const f = facilityAt(s, player, i.at)!;
      return [{ type: 'loadStarted', player, at: i.at, carType: f.outboundBox[0]!.type }];
    }

    case 'laborer.advanceLoad': {
      const f = facilityAt(s, player, i.at)!;
      const load = f.menAtWork[i.box]!;
      const next = load.dir === 'out' ? i.box + 1 : i.box - 1;

      if (next >= f.menAtWork.length) {
        // Outbound complete: the load goes onto the spotted car (§9.3).
        return [
          { type: 'loadCompleted', player, at: i.at, carType: load.type },
          { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightLoad' },
        ];
      }
      if (next < 0) {
        // Inbound complete: the load reaches the red Unloading box (§9.3).
        return [
          { type: 'unloadCompleted', player, at: i.at, carType: load.type },
          { type: 'revenueChanged', player, delta: 1, total: revenueAfter(s, player, 1), reason: 'freightUnload' },
        ];
      }
      return [{ type: 'loadAdvanced', player, at: i.at, fromBox: i.box, toBox: next }];
    }

    case 'laborer.beginUnload': {
      const f = facilityAt(s, player, i.at)!;
      return [
        { type: 'unloadBegan', player, at: i.at, carType: f.industryTrack.cars[i.carIndex]!.type },
      ];
    }

    case 'loadUnload.end':
      return [{ type: 'phaseEnded', player, phase: 'loadUnload' }];

    case 'redFlag.play':
      return [{ type: 'phaseEnded', player, phase: 'redFlag' }];

    default:
      return [];
  }
}

function revenueAfter(s: GameState, player: PlayerIndex, delta: number): number {
  return (s.players[player]?.revenue ?? 0) + delta;
}

/** §7 — from the rolled slot, walk down the Timetable column, wrapping at the bottom. */
function findTimetableSlot(s: GameState, from: number): number | null {
  for (let i = 0; i < s.timetable.length; i++) {
    const slot = (from + i) % s.timetable.length;
    if (s.timetable[slot] === null) return slot;
  }
  return null;
}

// ---------------------------------------------------------------------------
// reduce — the ONLY mutator. state = fold(events).
// ---------------------------------------------------------------------------

export function reduce(s: GameState, e: GameEvent): void {
  switch (e.type) {
    case 'localOpsOptionChosen':
      s.turn.option = e.option;
      break;

    case 'trayMoved': {
      const tray = s.trays.get(e.trayId)!;
      const owner = tray.position.at === 'grid' ? tray.position.owner : 0;
      tray.position = { at: 'grid', owner, coord: e.to };
      if (e.facing) tray.facing = e.facing;
      s.turn.movesRemaining = e.movesRemaining;

      // An A/D track is held only while the train is actually standing at the Office (§2.1).
      // Leaving it out of sync means the Office looks permanently full and every arrival collides.
      const area = areaOf(s, owner);
      const atOffice =
        e.to.row === area.officeCoord.row && e.to.col === area.officeCoord.col;
      area.adOccupancy = area.adOccupancy.filter((t) => t !== e.trayId);
      if (atOffice) area.adOccupancy.push(e.trayId);
      break;
    }

    case 'carsCoupled': {
      const tray = s.trays.get(e.trayId)!;
      const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
      tray.consist.push(...e.stock);
      // Every card along the path is cleared; the event carries what was taken.
      for (const card of area.grid.values()) {
        if (card.standing.length > 0) card.standing = [];
        if (card.facility) card.facility.industryTrack.cars = [];
      }
      break;
    }

    case 'consistSorted': {
      const tray = s.trays.get(e.trayId)!;
      tray.consist = e.after.map((c) => ({ ...c }));
      // "Spends one move in the yard" — the sort costs a Move.
      s.turn.movesRemaining = Math.max(0, s.turn.movesRemaining - 1);
      break;
    }

    case 'carsDropped': {
      const tray = s.trays.get(e.trayId)!;
      const area = areaOf(s, tray.position.at === 'grid' ? tray.position.owner : 0);
      tray.consist.splice(tray.consist.length - e.stock.length, e.stock.length);
      const card = area.grid.get(coordKey(e.at));
      // On a Facility card the industry track is where cars stand (§9.3).
      if (card) carsOn(card).push(...e.stock);
      break;
    }

    case 'departmentRefilled':
      s.decks.homeOffice.pop();
      s.decks.departments[e.slot] = e.cardId;
      break;

    case 'cardDrawn': {
      const hand = s.decks.hands.get(e.player) ?? [];
      if (e.source === 'homeOffice') s.decks.homeOffice.pop();
      else if (e.slot !== undefined) s.decks.departments[e.slot] = null;
      hand.push(e.cardId);
      s.decks.hands.set(e.player, hand);
      s.turn.drawnThisTurn = true;
      break;
    }

    case 'cardPlayed': {
      const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId);
      s.decks.hands.set(e.player, hand);
      const card = s.cards.get(e.cardId)!;
      const area = areaOf(s, e.player);
      if (e.placement && (card.kind.kind === 'track' || card.kind.kind === 'freightFacility')) {
        const built = protoCard(card.kind, e.variant);
        if (built) {
          if (card.kind.kind === 'freightFacility') built.facility = buildFreightFacility(card.kind.facility);
          area.grid.set(coordKey(e.placement), built);
          extendLimitsIfNeeded(area, e.placement);
        }
      } else if (e.placement && card.kind.kind === 'modifier') {
        // A Modifier stays on the board beside its Facility, and its effect is applied. It used to
        // be discarded straight to the Salvage Yard, so playing one did literally nothing.
        area.grid.set(coordKey(e.placement), {
          geometry: { kind: 'modifier', modifier: card.kind.modifier },
          baseOperationalRail: false,
          standing: [],
          facility: null,
          modifiers: [],
    enhancements: [],
        });
        applyModifier(area, e.placement, card.kind.modifier);
      } else if (card.kind.kind !== 'office') {
        s.decks.salvageYard.push(e.cardId);
      }
      break;
    }

    case 'mainlineModified': {
      const node = s.division.nodes[e.node];
      if (node?.kind === 'mainline') {
        if (e.became) node.card = e.became as MainlineKind;
        else node.modifiers = [...(node.modifiers ?? []), e.key];
      }
      spendCard(s, e.player, e.cardId);
      break;
    }

    case 'redFlagsSet': {
      const node = s.division.nodes[e.node];
      if (node?.kind === 'mainline') {
        node.redFlagged = [...(node.redFlagged ?? []), e.trayId];
      }
      spendCard(s, e.player, e.cardId);
      break;
    }

    case 'flyingSwitch': {
      const tray = s.trays.get(e.trayId);
      const area = areaOf(s, e.player);
      const card = area.grid.get(coordKey(e.to));
      if (tray && card) {
        tray.consist = tray.consist.slice(0, tray.consist.length - e.stock.length);
        const track = card.facility?.industryTrack;
        if (track) track.cars.push(...e.stock);
        else card.standing.push(...e.stock);
      }
      s.turn.movesRemaining -= 1;
      spendCard(s, e.player, e.cardId);
      break;
    }

    case 'trackLaid': {
      const area = areaOf(s, e.player);
      area.trackSupply.set(`${e.geometry}:${e.hand}`, e.remaining);
      s.turn.laidThisTurn = true;
      const built = protoTrackCard(e.geometry as never, e.hand as never, e.variant);
      if (built) {
        area.grid.set(coordKey(e.at), built);
        extendLimitsIfNeeded(area, e.at);
      }
      break;
    }

    case 'officeUpgraded': {
      // Gap 8 — a property change, NOT a card swap. Swapping would orphan attached Secondary Track.
      const area = areaOf(s, e.player);
      area.tier = e.to;
      const officeCard = area.grid.get(coordKey(area.officeCoord));
      if (officeCard?.facility) {
        const p = officeProfile(e.to);
        officeCard.facility.porters = p.porters;
        officeCard.facility.capacity = { outbound: p.passengerOut, inbound: p.passengerIn };
        officeCard.facility.allows = { outbound: p.isPassengerFacility, inbound: p.isPassengerFacility };
      }
      break;
    }

    case 'cardDiscarded': {
      const hand = (s.decks.hands.get(e.player) ?? []).filter((c) => c !== e.cardId);
      s.decks.hands.set(e.player, hand);
      s.decks.departments[e.toSlot] = e.cardId;
      break;
    }

    case 'stockToOutbound': {
      const f = facilityAt(s, e.player, e.at)!;
      const idx = s.yards.divisionYard.findIndex(
        (c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
      );
      if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
      f.outboundBox.push(e.stock);
      s.turn.freightAgentUsed = true;
      break;
    }

    case 'inboundCleared': {
      const f = facilityAt(s, e.player, e.at)!;
      const idx = f.inboundBox.findIndex((c) => c.type === e.stock.type && c.loaded === e.stock.loaded);
      if (idx >= 0) f.inboundBox.splice(idx, 1);
      s.yards.classificationYard.push(e.stock);
      s.turn.freightAgentUsed = true;
      break;
    }

    case 'facilityUnjammed': {
      const f = facilityAt(s, e.player, e.at)!;
      if (e.from === 'menAtWork') {
        const idx = f.menAtWork.findIndex((l) => l !== null);
        if (idx >= 0) f.menAtWork[idx] = null;
      } else {
        const box = e.from === 'outbound' ? f.outboundBox : f.inboundBox;
        const idx = box.findIndex((c) => c.type === e.stock.type);
        if (idx >= 0) box.splice(idx, 1);
      }
      s.yards.classificationYard.push(e.stock);
      s.turn.freightAgentUsed = true;
      break;
    }

    case 'enhancementPlaced': {
      const rule = enhancementRule(e.key);
      if (rule?.placement === 'mainlineCard') {
        const node = s.division.nodes[e.at.col];
        // ABS Signals: trains on this card stop short rather than rear-ending each other.
        if (node?.kind === 'mainline') node.absSignals = true;
      } else {
        const card = areaOf(s, e.player).grid.get(coordKey(e.at));
        if (card) card.enhancements.push(e.key);
      }
      break;
    }

    case 'extraQueued':
      s.pendingExtras.push(e.trainNumber);
      break;

    case 'secondSectionOrdered':
      s.pendingSecondSections.push(e.trainNumber);
      break;

    case 'trainScheduled':
      s.timetable[e.slot] = e.trainNumber;
      s.rngState = e.rngState;
      s.decks.salvageYard.push(`train-${e.trainNumber}`);
      break;

    case 'carPlacedOnTrain': {
      const tray = s.trays.get(e.trayId)!;
      const idx = s.yards.divisionYard.findIndex(
        (c) => c.type === e.stock.type && c.loaded === e.stock.loaded,
      );
      if (idx >= 0) s.yards.divisionYard.splice(idx, 1);
      tray.consist.push(e.stock);
      break;
    }

    case 'passengersBoarded': {
      const f = facilityAt(s, e.player, e.at)!;
      const area = areaOf(s, e.player);
      const idx = f.outboundBox.findIndex((c) => c.type === 'coach' && c.loaded);
      const loaded = f.outboundBox.splice(idx, 1)[0]!;
      for (const id of area.adOccupancy) {
        const tray = s.trays.get(id);
        const ci = tray?.consist.findIndex((c) => c.type === 'coach' && !c.loaded) ?? -1;
        if (tray && ci >= 0) {
          s.yards.classificationYard.push(tray.consist[ci]!);
          tray.consist[ci] = loaded;
          break;
        }
      }
      f.usedThisStage.porters += 1;
      break;
    }

    case 'passengersDetrained': {
      const f = facilityAt(s, e.player, e.at)!;
      const area = areaOf(s, e.player);
      for (const id of area.adOccupancy) {
        const tray = s.trays.get(id);
        const ci = tray?.consist.findIndex((c) => c.type === 'coach' && c.loaded) ?? -1;
        if (tray && ci >= 0) {
          f.inboundBox.push(tray.consist[ci]!);
          tray.consist[ci] = { type: 'coach', loaded: false };
          break;
        }
      }
      f.usedThisStage.porters += 1;
      break;
    }

    case 'loadStarted': {
      const f = facilityAt(s, e.player, e.at)!;
      const idx = f.outboundBox.findIndex((c) => c.type === e.carType);
      if (idx >= 0) f.outboundBox.splice(idx, 1);
      f.menAtWork[0] = { type: e.carType, dir: 'out' };
      f.usedThisStage.laborers += 1;
      break;
    }

    case 'loadAdvanced': {
      const f = facilityAt(s, e.player, e.at)!;
      f.menAtWork[e.toBox] = f.menAtWork[e.fromBox]!;
      f.menAtWork[e.fromBox] = null;
      f.usedThisStage.laborers += 1;
      break;
    }

    case 'unloadCompleted': {
      const f = facilityAt(s, e.player, e.at)!;
      f.menAtWork[0] = null;
      f.inboundBox.push({ type: e.carType, loaded: true });
      f.usedThisStage.laborers += 1;
      break;
    }

    case 'loadCompleted': {
      const f = facilityAt(s, e.player, e.at)!;
      f.menAtWork[f.menAtWork.length - 1] = null;
      const ci = f.industryTrack.cars.findIndex((c) => !c.loaded && c.type === e.carType);
      if (ci >= 0) {
        s.yards.classificationYard.push(f.industryTrack.cars[ci]!);
        f.industryTrack.cars[ci] = { type: e.carType, loaded: true };
      }
      f.usedThisStage.laborers += 1;
      break;
    }

    case 'unloadBegan': {
      const f = facilityAt(s, e.player, e.at)!;
      const ci = f.industryTrack.cars.findIndex((c) => c.loaded);
      if (ci >= 0) f.industryTrack.cars[ci] = { type: e.carType, loaded: false };
      f.menAtWork[f.menAtWork.length - 1] = { type: e.carType, dir: 'in' };
      f.usedThisStage.laborers += 1;
      break;
    }

    case 'revenueChanged': {
      const p = s.players[e.player];
      if (p) p.revenue = e.total;
      break;
    }

    case 'phaseEnded':
      // The actor has finished; the phase driver moves on to the next player.
      if (e.phase !== 'redFlag') s.turn.done = true;
      break;

    case 'clearanceGiven':
      s.clock.pendingDecision = null;
      // Recorded for the asking train to consume; otherwise the driver asks again forever.
      s.clock.clearanceRuling = { train: e.trainId, allow: e.allow };
      break;

    default:
      break;
  }
}

/**
 * Builds the card a play would place, at the chosen orientation (Gap 11). Returns null when the
 * variant index is out of range, which `check` reports rather than silently defaulting — a wrong
 * orientation is a different card, not a detail.
 */
function protoCard(
  kind: { kind: string; geometry?: string; facility?: string },
  variant: number | undefined,
): TrackCard | null {
  const base = { standing: [], facility: null, modifiers: [], enhancements: [] };

  if (kind.kind === 'track') {
    const geometry = kind.geometry as TrackGeometry;
    const options = variantsFor(geometry);
    const v = options[variant ?? 0];
    if (!v) return null;
    return {
      geometry: {
        kind: 'track',
        geometry,
        ...(v.axis ? { axis: v.axis } : {}),
        ...(v.turnout ? { turnout: v.turnout } : {}),
        ...(v.bypass ? { bypass: v.bypass } : {}),
      },
      baseOperationalRail: geometry !== 'turnout',
      ...base,
    };
  }

  const options = facilityVariants();
  const v = options[variant ?? 0];
  if (!v) return null;
  return {
    geometry: {
      kind: 'facility',
      facility: kind.facility as never,
      ...(v.axis ? { axis: v.axis } : {}),
    },
    baseOperationalRail: true,
    ...base,
  };
}

/**
 * Instantiates a Freight Facility from its catalogue profile (card-reference.md §2).
 *
 * This was missing: placed facility cards were built with `facility: null`, making them inert —
 * they could never be stocked, worked, or scored from. Every freight card played was dead weight.
 */
function buildFreightFacility(kind: FreightKind): Facility {
  const p = FREIGHT_PROFILES.find((f) => f.kind === kind);
  if (!p) throw new Error(`unknown freight facility: ${kind}`);
  return {
    kind: 'freight',
    subtype: kind,
    allows: {
      outbound: p.flow === 'outbound' || p.flow === 'both',
      inbound: p.flow === 'inbound' || p.flow === 'both',
    },
    outboundBox: [],
    inboundBox: [],
    // The design gives every industry ONE car out and ONE loader; capacity is grown by the
    // industry-specific modifier cards, not printed large on the industry itself.
    capacity: { outbound: p.baseOut, inbound: p.baseIn },
    menAtWork: [null, null, null],
    industryTrack: { length: Math.max(1, p.baseOut + p.baseIn), cars: [] },
    laborers: p.baseLoaders,
    porters: 0,
    usedThisStage: { laborers: 0, porters: 0 },
  };
}

/**
 * The Facility a Modifier at `coord` would serve — any of the nine nearby spots (§9).
 *
 * SIMPLIFICATION, deliberate and flagged: §9 says that when a Modifier touches two Facilities its
 * effect may be used on only one per Stage. This applies the effect permanently to the first
 * Facility found instead. Modelling the per-Stage choice needs an extra decision point in the
 * Load/Unload phase and is not worth it until the mechanic has been played.
 */
function adjacentFacilityCoord(area: OfficeArea, coord: GridCoord): GridCoord | null {
  // Q7 — nothing exists above the Running Track, so §9's "nine nearby spots" is really six.
  if (coord.row > area.runningRow) return null;
  for (let dr = -1; dr <= 1; dr++) {
    for (let dc = -1; dc <= 1; dc++) {
      if (dr === 0 && dc === 0) continue;
      const c = { row: coord.row + dr, col: coord.col + dc };
      if (c.row > area.runningRow) continue;
      if (area.grid.get(coordKey(c))?.facility) return c;
    }
  }
  return null;
}

/**
 * The three defensive Enhancements protect against cards that only exist in a multiplayer deck, so
 * their placement and state are implemented and their effect is read at the point of attack:
 *
 *   - **Facing Point Locks** — prevents `Derail` being played on you (Action card).
 *   - **Water Column**       — lets you remove a Watertower from your district (Space-use card).
 *   - **Overpass**           — removes the restrictions of a played Railroad Crossing (Action card).
 *
 * In solitaire the opponent-directed cards are not in the deck (Q6), so these never fire. They are
 * queried here rather than being special-cased at each attack site.
 */
export function hasDistrictEnhancement(area: OfficeArea, key: string): boolean {
  return [...area.grid.values()].some((c) => c.enhancements.includes(key));
}

/** Facing Point Locks blocks a Derail played at this district. */
export function isProtectedFromDerail(area: OfficeArea): boolean {
  return hasDistrictEnhancement(area, 'facingPointLocks');
}

/** A Water Column lets its owner clear a Watertower off their own grid. */
export function watertowersRemovable(area: OfficeArea): GridCoord[] {
  if (!hasDistrictEnhancement(area, 'waterColumn')) return [];
  const out: GridCoord[] = [];
  for (const [key, card] of area.grid) {
    if (card.geometry.kind !== 'spaceUse' || card.geometry.key !== 'watertower') continue;
    const [row, col] = key.split(',').map(Number);
    out.push({ row: row!, col: col! });
  }
  return out;
}

/**
 * Enhancements have per-card placement rules (implications.md §7):
 *   - Interlocking, Water Column, Telegraph → a Running Track straight
 *   - Yard Office, Small Yard              → a Secondary Track straight
 *   - Telephone / Radio                    → stacked on the card below them
 *   - Facing Point Locks                   → needs an Interlocking in the district
 *   - ABS Signals                          → a Mainline card, not the Office Area
 */
export function checkEnhancementPlacement(
  s: GameState,
  area: OfficeArea,
  key: string,
  placement: GridCoord,
): RejectionCode | null {
  const rule = enhancementRule(key);
  if (!rule) return 'NOT_IMPLEMENTED';

  // ABS Signals goes on a Mainline card; `placement.col` names which one.
  if (rule.placement === 'mainlineCard') {
    const node = s.division.nodes[placement.col];
    return node && node.kind === 'mainline' ? null : 'NOT_CONNECTED';
  }

  const card = area.grid.get(coordKey(placement));
  if (!card) return 'NOT_CONNECTED';
  if (card.enhancements.includes(key)) return 'OPTION_ALREADY_CHOSEN';

  if (rule.requiresOnSameCard && !card.enhancements.includes(rule.requiresOnSameCard)) {
    return 'NOT_CONNECTED';
  }
  if (rule.requiresInDistrict) {
    const present = [...area.grid.values()].some((c) =>
      c.enhancements.includes(rule.requiresInDistrict!),
    );
    if (!present) return 'NOT_CONNECTED';
  }

  const onRunning = placement.row === area.runningRow;
  const isStraight =
    card.geometry.kind === 'track' && card.geometry.geometry === 'straight';

  switch (rule.placement) {
    case 'runningTrackStraight':
      return onRunning && isStraight ? null : 'NOT_CONNECTED';
    case 'secondaryTrackStraight':
      return !onRunning && isStraight ? null : 'NOT_CONNECTED';
    case 'onCard':
      return null;
    default:
      return 'NOT_CONNECTED';
  }
}

/** Removes a played card from its owner's hand and sends it to the Salvage Yard. */
function spendCard(s: GameState, player: PlayerIndex, cardId: CardId): void {
  s.decks.hands.set(player, (s.decks.hands.get(player) ?? []).filter((c) => c !== cardId));
  s.decks.salvageYard.push(cardId);
}

/** A track piece from the player's own supply, at the chosen rotation. */
function protoTrackCard(
  geometry: TrackGeometry,
  hand: Hand,
  variant: number | undefined,
): TrackCard | null {
  const options = variantsFor(geometry);
  const v = options[variant ?? 0];
  if (!v) return null;
  return {
    geometry: {
      kind: 'track',
      geometry,
      ...(v.axis ? { axis: v.axis } : {}),
      ...(v.turnout ? { turnout: v.turnout } : {}),
      ...(hand !== 'none' ? { hand } : {}),
    },
    baseOperationalRail: geometry !== 'turnout',
    standing: [],
    facility: null,
    modifiers: [],
    enhancements: [],
  };
}

/**
 * Q4 — would building `kind` here conflict with something already in the district?
 * The relation is symmetric, so checking either direction is enough.
 */
export function isLockedOut(area: OfficeArea, kind: FreightKind): boolean {
  const wanted = industryProfile(kind);
  for (const card of area.grid.values()) {
    if (card.geometry.kind !== 'facility') continue;
    const present = card.geometry.facility;
    if (wanted.lockouts.includes(present)) return true;
    if (industryProfile(present).lockouts.includes(kind)) return true;
  }
  return false;
}

/** Applies a Modifier's printed effect to the Facility it was placed beside (content.ts). */
function applyModifier(area: OfficeArea, coord: GridCoord, modifier: ModifierKind): void {
  const target = adjacentFacilityCoord(area, coord);
  if (!target) return;
  const f = area.grid.get(coordKey(target))?.facility;
  if (!f) return;
  const m = modifierProfile(modifier);
  f.capacity.outbound += m.addOut;
  f.capacity.inbound += m.addIn;
  f.laborers += m.addLoaders;
  f.porters += m.addPorters;
  if (m.addOut > 0 || m.addIn > 0) f.industryTrack.length += m.addOut + m.addIn;
}

/** §2.1, Gap 4a — extending the Running Track pushes the Limits sign outward. */
/**
 * §2.1 Gap 4a — "when you extend your Running Track, the Limits sign MOVES outwards with it".
 *
 * The sign is a physical card, not just a recorded column. Moving only the coordinate left the
 * Limits card stranded mid-track: a district grew to
 *   (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2)=Freight House …
 * with everything beyond (0,-2) built OUTSIDE a sign that never moved. That is not cosmetic —
 * §8.1 and §10 both reason about "the track between the train and the Limits", Interlocking holds
 * an arrival AT the Limits, and running past a player's Limits is what makes a collision his fault.
 */
function extendLimitsIfNeeded(area: OfficeArea, placed: GridCoord): void {
  if (placed.row !== area.runningRow) return;

  if (placed.col <= area.limitsWest.col) {
    area.limitsWest = { row: placed.row, col: placed.col - 1 };
    area.grid.set(coordKey(area.limitsWest), limitsCard());
  }
  if (placed.col >= area.limitsEast.col) {
    area.limitsEast = { row: placed.row, col: placed.col + 1 };
    area.grid.set(coordKey(area.limitsEast), limitsCard());
  }
}

/**
 * §8.2 — may this car be coupled onto this train right now?
 *
 * "The train must be in the order listed on the train's card (engine on the front, Rolling Stock,
 * and possibly a Caboose). It may depart with FEWER Rolling Stock than listed, but not out of
 * order." Fewer is allowed; more, or of the wrong category, is not.
 *
 * SHARED. Both `check` and the New Train Phase's "is there a suitable car in the yard" test call
 * this. A second copy stalled the game outright: the phase believed a car could be added while
 * `check` rejected every option, so the Stage never completed.
 */
export function acceptsCar(tray: CrewTray, carType: CarType): boolean {
  const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
  if (!profile) return true;

  const cat = (t: CarType): 'coach' | 'caboose' | 'freight' =>
    t === 'coach' ? 'coach' : t === 'caboose' ? 'caboose' : 'freight';
  const adding = cat(carType);
  const already = tray.consist.filter((c) => cat(c.type) === adding).length;
  const allowed =
    adding === 'coach'
      ? profile.consist.coach
      : adding === 'caboose'
        ? profile.consist.caboose
        : profile.consist.freight;
  if (already >= allowed) return false;

  // The caboose rides last (§A.3), so nothing may be coupled behind one.
  if (adding !== 'caboose' && tray.consist.some((c) => c.type === 'caboose')) return false;

  // The card also narrows WHICH freight types it will take.
  const types = profile.consist.freightTypes;
  if (adding === 'freight' && types && !types.includes(carType)) return false;

  return true;
}

/** A fresh Limits sign. The set is "2N + spares" (§12), so relocating one is not a supply question. */
function limitsCard(): TrackCard {
  return {
    geometry: { kind: 'limits' },
    baseOperationalRail: true,
    standing: [],
    facility: null,
    modifiers: [],
    enhancements: [],
  };
}

// ---------------------------------------------------------------------------
// Public entry point
// ---------------------------------------------------------------------------

export function applyIntent(s: GameState, player: PlayerIndex, i: Intent): ApplyResult {
  const code = check(s, player, i);
  if (code) return { ok: false, code, message: `${i.type} rejected: ${code}` };

  const events = execute(s, player, i);
  for (const e of events) reduce(s, e);
  return { ok: true, events };
}

export { isOperationalRail, destinationsFor };
