/**
 * Component 5 — The phase driver.
 *
 * `advance(state) -> { events, needsInput }`. Everything the game does WITHOUT a player acting:
 * Mainline movement, highball evaluation, collisions, train make-up, the Stage and Day clock,
 * Superintendent rotation, and victory checks.
 *
 * See architecture/components.md §2 A.5. This lives inside the engine rather than the session
 * layer so that all rules knowledge stays in one pure deterministic place — which is also what
 * makes the balance harness a plain loop over `advance` and `applyIntent`, with no server.
 *
 * USAGE:
 *   while (true) {
 *     const r = advance(state);
 *     if (r.needsInput || state.status === 'finished') break;
 *   }
 */

import {
  COLLISION_PENALTY,
  MAINLINE_PROFILES,
  enhancementRule,
  consistSize,
  crossingStages,
  trainProfile,
  MOVES_PER_LOCAL_OPS,
  MOVES_PER_LOCAL_OPS_NIGHT,
  STAGES_PER_DAY,
  STAGES_PER_SHIFT,
  collectiveRevenueFloor,
  lengthProfile,
  officeProfile,
} from './content.ts';
import type { Direction } from './content.ts';
import type { GameEvent } from './events.ts';
import { acceptsCar, areaOf } from './apply.ts';
import { legalActions } from './legal.ts';
import type { CrewTray, DivisionNode, GameState, PlayerIndex, RollingStock, TrayId } from './state.ts';
import { coordKey, freshTurn, totalRevenue } from './state.ts';

export type AdvanceResult = {
  events: GameEvent[];
  /** True when the game is waiting on a player. The caller should stop pumping. */
  needsInput: boolean;
};

const NIGHT_STAGES = new Set([1, 2, 3, 11, 12]);

function movesForStage(s: GameState): number {
  return s.config.optionalRules.reducedVisibility && NIGHT_STAGES.has(s.clock.stage)
    ? MOVES_PER_LOCAL_OPS_NIGHT
    : MOVES_PER_LOCAL_OPS;
}



// ---------------------------------------------------------------------------
// Division navigation
// ---------------------------------------------------------------------------

function nodeIndexOfOffice(s: GameState, owner: PlayerIndex): number {
  return s.division.nodes.findIndex((n) => n.kind === 'office' && n.owner === owner);
}

const step = (d: Direction): number => (d === 'east' ? 1 : -1);

// ---------------------------------------------------------------------------
// advance
// ---------------------------------------------------------------------------

export function advance(s: GameState): AdvanceResult {
  const events: GameEvent[] = [];
  if (s.status === 'finished') return { events, needsInput: false };

  // The Superintendent's clearance ruling interrupts the Mainline Phase (§8.1).
  if (s.clock.pendingDecision !== null) return { events, needsInput: true };

  switch (s.clock.phase) {
    case 'localOps':
      return playerPhase(s, events, 'localOps');
    case 'newTrain':
      return newTrainPhase(s, events);
    case 'mainline':
      return mainlinePhase(s, events);
    case 'loadUnload':
      return playerPhase(s, events, 'loadUnload');
    case 'shiftChange':
      return shiftChange(s, events);
  }
}

// ---------------------------------------------------------------------------
// Player-driven phases (Local Ops, Load/Unload)
// ---------------------------------------------------------------------------

function playerPhase(
  s: GameState,
  events: GameEvent[],
  phase: 'localOps' | 'loadUnload',
): AdvanceResult {
  // A Freight Agent operation is a whole action in itself, so the turn ends with it (§6.3).
  if (phase === 'localOps' && s.turn.option === 'freightAgent' && s.turn.freightAgentUsed) {
    s.turn.done = true;
  }
  // Spending the last Move ends a switching turn without needing an explicit end (§6.1).
  if (phase === 'localOps' && s.turn.option === 'switch' && s.turn.movesRemaining === 0) {
    s.turn.done = true;
  }

  if (!s.turn.done) {
    s.clock.currentActor = actorAt(s, s.clock.actorOffset);

    // SAFETY NET. If the actor has no legal action at all, the turn ends rather than deadlocking.
    // This should never fire — an option with no follow-up is already unavailable (§6, apply.ts) —
    // but a rules gap that stranded a player would otherwise hang the game rather than fail
    // visibly, and a hung game is far harder to diagnose than a forfeited turn.
    if (legalActions(s, s.clock.currentActor).length === 0) {
      s.turn.done = true;
    } else {
      return { events, needsInput: true };
    }
  }

  s.clock.actorOffset += 1;
  s.turn = freshTurn(movesForStage(s));

  if (s.clock.actorOffset >= s.players.length) {
    return { events: [...events, ...enterPhase(s, nextPhase(phase))], needsInput: false };
  }

  s.clock.currentActor = actorAt(s, s.clock.actorOffset);
  events.push({ type: 'actorChanged', player: s.clock.currentActor });
  return { events, needsInput: false };
}

function actorAt(s: GameState, offset: number): PlayerIndex {
  return (s.clock.superintendent + offset) % s.players.length;
}

function nextPhase(p: GameState['clock']['phase']): GameState['clock']['phase'] {
  switch (p) {
    case 'localOps':
      return 'newTrain';
    case 'newTrain':
      return 'mainline';
    case 'mainline':
      return 'loadUnload';
    case 'loadUnload':
      return 'shiftChange';
    default:
      return 'localOps';
  }
}

function enterPhase(s: GameState, phase: GameState['clock']['phase']): GameEvent[] {
  s.clock.phase = phase;
  s.clock.actorOffset = 0;
  s.turn = freshTurn(movesForStage(s));
  s.clock.currentActor = phase === 'mainline' ? null : actorAt(s, 0);
  return [
    { type: 'phaseBegan', phase },
    { type: 'actorChanged', player: s.clock.currentActor },
  ];
}

// ---------------------------------------------------------------------------
// New Train Phase (§7)
// ---------------------------------------------------------------------------

function newTrainPhase(s: GameState, events: GameEvent[]): AdvanceResult {
  // Make up any Timetabled Train due this Stage, if a Crew Tray is free (§7).
  const due = s.timetable[s.clock.stage - 1];
  if (due !== null && due !== undefined && !trainRunning(s, due, false)) {
    if (s.freeTrays.length === 0) {
      // "Held train" — no crew available. It waits; the Stage moves on (§7).
      events.push({ type: 'trainHeld', trainNumber: due, reason: 'no free Crew Tray' });
      return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
    }
    const profile = trainProfile(due, false);
    if (profile) {
      const trayId = s.freeTrays.pop()!;
      const direction: Direction = profile.direction === 'east' ? 'east' : 'west';
      // An eastbound train starts at the Western Division Point and runs east.
      const side: Direction = direction === 'east' ? 'west' : 'east';
      const tray: CrewTray = {
        id: trayId,
        trainNumber: due,
        trainIsExtra: false,
        engineFront: true,
        consist: [],
        direction,
        position: { at: 'divisionPoint', side },
        movesUsed: 0,
      };
      s.trays.set(trayId, tray);
      const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side);
      if (dp && dp.kind === 'divisionPoint') dp.holding.push(trayId);
      events.push({
        type: 'trainMadeUp',
        trainNumber: due,
        isExtra: false,
        at: side === 'west' ? 'West Division Point' : 'East Division Point',
        direction,
      });
    }
  }

  // Q9 — a second section runs immediately behind its first, with the same number, rules and
  // consist. It needs its own Crew Tray, and because it follows a train of the same number into
  // the same Subdivision it will usually force the §8.1 clearance decision.
  if (s.pendingSecondSections.length > 0 && s.freeTrays.length > 0) {
    const number = s.pendingSecondSections.shift()!;
    const profile = trainProfile(number, false);
    if (profile) {
      const trayId = s.freeTrays.pop()!;
      const direction: Direction = profile.direction === 'east' ? 'east' : 'west';
      const side: Direction = direction === 'east' ? 'west' : 'east';
      s.trays.set(trayId, {
        id: trayId,
        trainNumber: number,
        trainIsExtra: false,
        engineFront: true,
        consist: [],
        direction,
        position: { at: 'divisionPoint', side },
        movesUsed: 0,
      });
      const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === side);
      if (dp?.kind === 'divisionPoint') dp.holding.push(trayId);
      events.push({
        type: 'trainMadeUp',
        trainNumber: number,
        isExtra: false,
        at: side === 'west' ? 'West Division Point' : 'East Division Point',
        direction,
      });
    }
  }

  // §7 — "Once all timetabled trains are created, if there is a played Extra Train card and an
  // available Crew Tray, the player who played the card may place the Crew Tray in either division
  // point for immediate departure." An Extra runs once and is gone.
  //
  // SIMPLIFICATION, flagged: the rules let the player choose the direction. This launches eastbound
  // from the West Division Point. Giving the player the choice needs its own intent.
  if (s.pendingExtras.length > 0 && s.freeTrays.length > 0) {
    const number = s.pendingExtras.shift()!;
    const trayId = s.freeTrays.pop()!;
    s.trays.set(trayId, {
      id: trayId,
      trainNumber: number,
      trainIsExtra: true,
      engineFront: true,
      consist: [],
      direction: 'east',
      position: { at: 'divisionPoint', side: 'west' },
      movesUsed: 0,
    });
    const dp = s.division.nodes.find((n) => n.kind === 'divisionPoint' && n.side === 'west');
    if (dp?.kind === 'divisionPoint') dp.holding.push(trayId);
    events.push({
      type: 'trainMadeUp',
      trainNumber: number,
      isExtra: true,
      at: 'West Division Point',
      direction: 'east',
    });
  }

  // Gap 9 — the car-placement round REPEATS until the consist is full or no suitable car remains.
  const filling = trainNeedingCars(s);
  if (filling) {
    s.clock.currentActor = actorAt(s, s.clock.actorOffset % s.players.length);
    return { events, needsInput: true };
  }

  return { events: [...events, ...enterPhase(s, 'mainline')], needsInput: false };
}

function trainRunning(s: GameState, number: number, isExtra: boolean): boolean {
  for (const t of s.trays.values()) {
    if (t.trainNumber === number && t.trainIsExtra === isExtra) return true;
  }
  return false;
}

/** A made-up train still short of its consist spec, with a suitable car available. */
function trainNeedingCars(s: GameState): TrayId | null {
  for (const [id, tray] of s.trays) {
    if (tray.trainNumber === null) continue;
    if (tray.position.at !== 'divisionPoint') continue;
    const profile = trainProfile(tray.trainNumber, tray.trainIsExtra);
    if (!profile) continue;
    const want = consistSize(profile.consist);
    if (tray.consist.length >= want) continue;
    // Consists are specified by CATEGORY — "Freight (2)" is any two freight cars — so any car in
    // the yard is potentially suitable unless the card narrows it.
    // Ask the SAME predicate `check` uses. A separate copy of this test stalled the game: the phase
    // believed a car could be added while check rejected every option, so the Stage never ended.
    const suitable = s.yards.divisionYard.some((c) => acceptsCar(tray, c.type));
    if (suitable) return id;
  }
  return null;
}

// ---------------------------------------------------------------------------
// Mainline Phase (§8) — automatic, except the Superintendent's clearance ruling
// ---------------------------------------------------------------------------

function mainlinePhase(s: GameState, events: GameEvent[]): AdvanceResult {
  s.clock.currentActor = null;

  // §8 — trains move in numeric order, lowest first. Timetabled outranks an Extra of the same
  // number (Gap 5): sort by (number, isExtra) ascending.
  const order = [...s.trays.entries()]
    // §8 — "Any train holding at a player's Office ... or a Division Point must attempt to move."
    // Trains standing on an A/D track are included: they are exactly the ones due to highball.
    .filter(([, t]) => t.trainNumber !== null)
    .sort(([, a], [, b]) => {
      const d = (a.trainNumber ?? 0) - (b.trainNumber ?? 0);
      return d !== 0 ? d : Number(a.trainIsExtra) - Number(b.trainIsExtra);
    });

  for (const [id, tray] of order) {
    if (s.movedThisPhase.has(id)) continue;
    const moved = moveTrain(s, id, tray, events);
    if (moved === 'needsClearance') return { events, needsInput: true };
    // An expedited train may act twice in one Stage: it arrives and departs (Q3).
    if (moved === 'expedited') {
      const again = moveTrain(s, id, tray, events);
      if (again === 'needsClearance') return { events, needsInput: true };
    }
    s.movedThisPhase.add(id);
  }

  s.movedThisPhase = new Set();
  return { events: [...events, ...enterPhase(s, 'loadUnload')], needsInput: false };
}

type MoveOutcome = 'moved' | 'held' | 'needsClearance' | 'expedited';

/** Puts a train onto a Mainline card with its crossing time already computed. */
function enterMainline(
  s: GameState,
  node: Extract<DivisionNode, { kind: 'mainline' }>,
  id: TrayId,
  tray: CrewTray,
  index: number,
): void {
  const profile = trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra);
  const carriesPassengers = tray.consist.some((c) => c.type === 'coach');
  const stages = crossingStages(
    node.card,
    profile?.speed ?? 'slow',
    carriesPassengers,
    node.modifiers ?? [],
    tray.direction,
    node.gradeUp ?? 'east',
  );
  node.transits.push({ tray: id, stagesRemaining: stages, direction: tray.direction });
  tray.position = { at: 'mainline', index };
}

/**
 * Telegraph / Telephone / Radio — "once a day, when dispatching facing trains, add +N to the other
 * train's number". Spends the best available device the owning player has, once per Day each.
 *
 * Returns the bonus applied to the opposing train's number (0 if none was available or needed).
 */
function spendDispatchBonus(
  s: GameState,
  tray: CrewTray,
  other: CrewTray,
  events: GameEvent[],
): number {
  const mine = tray.trainNumber ?? 99;
  const theirs = other.trainNumber ?? 99;

  const area = s.officeAreas.get(s.clock.superintendent);
  if (!area) return 0;

  // Best device first — Radio (+12) beats Telephone (+8) beats Telegraph (+4).
  for (const key of ['radio', 'telephone', 'telegraph'] as const) {
    if (area.dispatchUsedToday.includes(key)) continue;
    const present = [...area.grid.values()].some((c) => c.enhancements.includes(key));
    if (!present) continue;
    const bonus = enhancementRule(key)?.dispatchBonus ?? 0;
    // Spend it only if it actually wins the meet — the device makes the OTHER train count as
    // junior, so ours may proceed.
    if (mine >= theirs + bonus) continue;
    area.dispatchUsedToday.push(key);
    events.push({
      type: 'dispatchBonusUsed',
      key,
      bonus,
      trainNumber: tray.trainNumber ?? 0,
      againstTrain: other.trainNumber ?? 0,
    });
    return bonus;
  }
  return 0;
}

/** Q3 — an expedited train does not spend a Stage standing at the Office. */
function isExpedited(tray: CrewTray): boolean {
  return trainProfile(tray.trainNumber ?? 0, tray.trainIsExtra)?.rules.expedite === true;
}

function moveTrain(
  s: GameState,
  id: TrayId,
  tray: CrewTray,
  events: GameEvent[],
): MoveOutcome {
  const dir = step(tray.direction);

  if (tray.position.at === 'divisionPoint') {
    const dpIndex = s.division.nodes.findIndex(
      (n) => n.kind === 'divisionPoint' && n.side === (tray.position as { side: Direction }).side,
    );
    const target = dpIndex + dir;
    const node = s.division.nodes[target];
    if (!node || node.kind !== 'mainline') return 'held';

    const clearance = evaluateClearance(s, id, tray, target, events);
    if (clearance === 'blocked') return 'held';
    if (clearance === 'ask') return 'needsClearance';

    enterMainline(s, node, id, tray, target);
    const dp = s.division.nodes[dpIndex];
    if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
    events.push({ type: 'phaseBegan', phase: `train ${tray.trainNumber} highballed` });
    return 'moved';
  }

  // §8.1 — a train standing on an A/D track attempts to highball onto the next Mainline card.
  // Without this a train that arrives at an Office never leaves, holding an A/D track forever and
  // colliding with every train that follows it.
  if (tray.position.at === 'grid') {
    const owner = tray.position.owner;
    const area = areaOf(s, owner);
    // Only a train at the Office itself is eligible; one on Secondary Track is not (§8.1, Gap 2b).
    if (
      tray.position.coord.row !== area.officeCoord.row ||
      tray.position.coord.col !== area.officeCoord.col
    ) {
      return 'held';
    }

    const officeIndex = nodeIndexOfOffice(s, owner);
    const target = officeIndex + dir;
    const node = s.division.nodes[target];
    if (!node) return 'held';

    if (node.kind === 'mainline') {
      const clearance = evaluateClearance(s, id, tray, target, events);
      if (clearance === 'blocked') return 'held';
      if (clearance === 'ask') return 'needsClearance';

      enterMainline(s, node, id, tray, target);
      area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
      events.push({
        type: 'trainHighballed',
        trainNumber: tray.trainNumber ?? 0,
        from: 'the Office',
        to: 'the Mainline',
      });
      return 'moved';
    }

    if (node.kind === 'divisionPoint') {
      area.adOccupancy = area.adOccupancy.filter((t) => t !== id);
      retireTrain(s, id, tray, events);
      return 'moved';
    }

    return 'held';
  }

  if (tray.position.at === 'mainline') {
    const { index } = tray.position;
    const node = s.division.nodes[index];
    if (!node || node.kind !== 'mainline') return 'held';

    const transit = node.transits.find((t) => t.tray === id);
    if (!transit) return 'held';

    // Q1/Q2 — crossing takes a whole number of Stages set by the card's speed and the train's
    // Fast/Slow class. Count it down rather than stepping through printed cells.
    if (transit.stagesRemaining > 1) {
      transit.stagesRemaining -= 1;
      return 'moved';
    }

    // Off the end of the card: into the adjoining Limit, then straight to the Office (§8.2).
    const target = index + dir;
    const dest = s.division.nodes[target];
    node.transits = node.transits.filter((t) => t.tray !== id);
    // Red Flags protect a train while it is stopped here; once it rolls, the flags come in.
    if (node.redFlagged) node.redFlagged = node.redFlagged.filter((t) => t !== id);

    if (!dest) return 'held';

    if (dest.kind === 'divisionPoint') {
      // The train has run the length of the Division and leaves the game (§2.3).
      dest.holding.push(id);
      tray.position = { at: 'divisionPoint', side: dest.side };
      retireTrain(s, id, tray, events);
      return 'moved';
    }

    if (dest.kind === 'office') {
      return arriveAtOffice(s, id, tray, dest.owner, events);
    }
  }

  return 'held';
}

/**
 * §8.1 — the highball conditions that involve the next Subdivision.
 *
 * A train moving TOWARDS the considered train is an absolute bar. A train moving the SAME
 * direction is the Superintendent's judgment call — and Gap 2 made the consequences automatic
 * precisely so that this decision carries full weight.
 */
function evaluateClearance(
  s: GameState,
  id: TrayId,
  tray: CrewTray,
  targetIndex: number,
  events: GameEvent[] = [],
): 'clear' | 'blocked' | 'ask' {
  // A ruling already given for this train is consumed here — this is what stops the driver from
  // re-asking the same question every time it re-evaluates the train.
  const ruling = s.clock.clearanceRuling;
  if (ruling && ruling.train === id) {
    s.clock.clearanceRuling = null;
    return ruling.allow ? 'clear' : 'blocked';
  }

  const node = s.division.nodes[targetIndex];
  if (!node || node.kind !== 'mainline') return 'clear';

  // Double Track and Uncontrolled Siding print "Trains may pass", so occupancy does not block.
  const profile = MAINLINE_PROFILES.find((m) => m.kind === node.card);
  if (profile?.trainsMayPass) return 'clear';

  for (const t of node.transits) {
    const other = t.tray;
    if (!other || other === id) continue;
    const otherTray = s.trays.get(other);
    if (!otherTray) continue;

    if (otherTray.direction !== tray.direction) {
      // §8.1 — a train moving TOWARDS the considered train is an absolute bar. That stands.
      //
      // The exception is dispatching technology. Telegraph/Telephone/Radio exist precisely so the
      // Superintendent can arrange a meet with a facing train: "once a day, when dispatching
      // facing trains, add +4/+8/+12 to the other train's number". Without a device there is no
      // way to pass the order, so the train simply holds.
      if (spendDispatchBonus(s, tray, otherTray, events) > 0) return 'clear';
      return 'blocked';
    }

    // Red Flags — "a stopped train is prevented from being hit; the approaching train is prevented
    // from moving". Flagging is per-train rather than per-card, so it protects one specific train
    // where ABS Signals protects everything on the card.
    if ((node.redFlagged ?? []).includes(other)) return 'blocked';

    // ABS Signals — "trains on this card will not rear-end each other; they stop short of a
    // collision". With signals in place a following train simply holds, and the Superintendent has
    // no judgment call to make. This is the amendment to Gap 2's unconditional collisions.
    if (node.absSignals) return 'blocked';

    // Same direction — the Superintendent must rule (§8.1, fourth condition).
    s.clock.pendingDecision = { train: id, occupiedBy: other };
    events.push({ type: 'clearanceRequested', trainId: id, occupiedBy: other });
    return 'ask';
  }
  return 'clear';
}

/**
 * §8.3 — arriving at an Office. Collisions here are AUTOMATIC (Gap 2a): if the trigger holds,
 * the collision happens, with no die roll and no judgment.
 */
function arriveAtOffice(
  s: GameState,
  id: TrayId,
  tray: CrewTray,
  owner: PlayerIndex,
  events: GameEvent[],
): MoveOutcome {
  const area = areaOf(s, owner);
  const capacity = officeProfile(area.tier).adTracks;
  const hasEnhancement = (key: string): boolean =>
    [...area.grid.values()].some((c) => c.enhancements.includes(key));

  // Yard Office — "an inbound train with NO COACHES that can make a single move to the yard office
  // track may arrive there, not at the Train Order Office". It sidesteps the A/D track entirely.
  const noCoaches = !tray.consist.some((c) => c.type === 'coach');
  if (noCoaches && hasEnhancement('yardOffice')) {
    for (const [key, card] of area.grid) {
      if (!card.enhancements.includes('yardOffice')) continue;
      const [row, col] = key.split(',').map(Number);
      tray.position = { at: 'grid', owner, coord: { row: row!, col: col! } };
      events.push({
        type: 'trainDiverted',
        trainNumber: tray.trainNumber ?? 0,
        to: 'the Yard Office',
        reason: 'no coaches, so it need not occupy the Train Order Office',
      });
      return 'moved';
    }
  }

  // Gap 2d — no room at the station is a collision, and it is the local player's fault (§10).
  if (area.adOccupancy.length >= capacity) {
    // Interlocking — "may stop an inbound train on the Limit Track". Instead of an automatic
    // collision, the train is held at the Limits until an A/D track frees up. This is the designed
    // answer to the A/D overflow that Gap 2d made fatal.
    if (hasEnhancement('interlocking')) {
      area.heldAtLimits.push(id);
      events.push({
        type: 'trainDiverted',
        trainNumber: tray.trainNumber ?? 0,
        to: 'the Limits',
        reason: 'Interlocking held it clear of a full Office instead of a collision',
      });
      return 'moved';
    }
    collide(s, owner, [id], events, 'no free A/D track', 'the Office');
    return 'moved';
  }

  // A train held at the Limits takes the first free A/D track before any newcomer.
  if (area.heldAtLimits.length > 0 && area.heldAtLimits[0] !== id) {
    const first = area.heldAtLimits.shift()!;
    area.adOccupancy.push(first);
    const held = s.trays.get(first);
    if (held) held.position = { at: 'grid', owner, coord: area.officeCoord };
  }
  area.heldAtLimits = area.heldAtLimits.filter((t) => t !== id);

  // §8.3 — cars standing on the Running Track between the Limits and the Office. A train at speed
  // is not expecting them (§A.4), so this is a collision too, not a coupling.
  const officeCard = area.grid.get(coordKey(area.officeCoord));
  if (officeCard && officeCard.standing.length > 0) {
    collide(s, owner, [id], events, 'cars fouling the Running Track', 'the Running Track');
    return 'moved';
  }

  area.adOccupancy.push(id);
  tray.position = { at: 'grid', owner, coord: area.officeCoord };
  events.push({
    type: 'trainArrived',
    trainNumber: tray.trainNumber ?? 0,
    consist: tray.consist.map((c) => ({ ...c })),
    office: officeProfile(area.tier).name,
  });

  // Q3 — an expedited train departs in the same Stage it arrived, so it is NOT added to
  // movedThisPhase and gets a second chance to move before the phase ends.
  if (isExpedited(tray)) return 'expedited';
  return 'moved';
}

function collide(
  s: GameState,
  faultPlayer: PlayerIndex,
  trains: TrayId[],
  events: GameEvent[],
  reason: string,
  where = 'the Office',
): void {
  // Record WHAT was lost before removing it. The log said only "COLLISION: NO FREE A/D TRACK", so a
  // player could see the 5 points go without learning which train had just been written off, or
  // what it was carrying.
  const lost: { label: string; consist: RollingStock[] }[] = [];
  for (const id of trains) {
    const tray = s.trays.get(id);
    if (!tray) continue;
    lost.push({
      label:
        tray.trainNumber === null
          ? 'the local crew'
          : `Train ${tray.trainIsExtra ? 'X' : ''}${tray.trainNumber}`,
      consist: [...tray.consist],
    });
    // Gap 2c — engines and cabooses return to the Division Yard, everything else to Classification.
    for (const car of tray.consist) {
      if (car.type === 'caboose') s.yards.divisionYard.push(car);
      else s.yards.classificationYard.push(car);
    }
    s.trays.delete(id);
    s.freeTrays.push(id);
  }

  if (lost.length > 0) {
    events.push({ type: 'trainsDestroyed', player: faultPlayer, trains: lost, reason, where });
  }

  s.collisionsToday += 1;
  const player = s.players[faultPlayer];
  if (player) {
    player.revenue -= COLLISION_PENALTY;
    events.push({
      type: 'revenueChanged',
      player: faultPlayer,
      delta: -COLLISION_PENALTY,
      total: player.revenue,
      reason: `collision: ${reason}`,
    });
  }
}

/** A completed run frees the crew; Extras go to the Salvage Yard (§2.3, Gap 2c). */
function retireTrain(s: GameState, id: TrayId, tray: CrewTray, events: GameEvent[]): void {
  for (const car of tray.consist) {
    if (car.type === 'caboose') s.yards.divisionYard.push(car);
    else s.yards.classificationYard.push(car);
  }
  s.trays.delete(id);
  s.freeTrays.push(id);
  const dp = s.division.nodes.find(
    (n) => n.kind === 'divisionPoint' && n.holding.includes(id),
  );
  if (dp?.kind === 'divisionPoint') dp.holding = dp.holding.filter((t) => t !== id);
  events.push({
    type: 'trainCompleted',
    trainNumber: tray.trainNumber ?? 0,
    consist: tray.consist.map((c) => ({ ...c })),
  });
}

// ---------------------------------------------------------------------------
// Shift change, Stage and Day advance
// ---------------------------------------------------------------------------

function shiftChange(s: GameState, events: GameEvent[]): AdvanceResult {
  // §5 — the Fedora passes every three Stages: shift changes at Stages 3, 6, 9 and 12.
  if (s.clock.stage % STAGES_PER_SHIFT === 0) {
    s.clock.superintendent = (s.clock.superintendent + 1) % s.players.length;
    events.push({ type: 'actorChanged', player: s.clock.superintendent });
  }

  // §9.1 — Laborers and Porters reset at the start of each Stage, not each Phase.
  for (const area of s.officeAreas.values()) {
    for (const card of area.grid.values()) {
      if (card.facility) card.facility.usedThisStage = { laborers: 0, porters: 0 };
    }
  }

  if (s.clock.stage >= STAGES_PER_DAY) {
    // Telegraph/Telephone/Radio are each usable once a Day.
    for (const area of s.officeAreas.values()) area.dispatchUsedToday = [];
    s.clock.day += 1;
    s.clock.stage = 1;
    s.collisionsToday = 0;
    events.push({ type: 'stageBegan', day: s.clock.day, stage: 1 });
    const finished = checkVictory(s, events);
    if (finished) return { events, needsInput: false };
  } else {
    s.clock.stage += 1;
    events.push({ type: 'stageBegan', day: s.clock.day, stage: s.clock.stage });
  }

  // §3.4 — Competitive only: three collisions in one Day and everyone loses.
  if (s.config.mode === 'competitive' && s.collisionsToday >= 3) {
    s.status = 'finished';
    s.outcome = { result: 'loss', winner: null, reason: 'collisionFloor' };
    return { events, needsInput: false };
  }

  return { events: [...events, ...enterPhase(s, 'localOps')], needsInput: false };
}

/** §3.3 — evaluated at the end of a Day. */
function checkVictory(s: GameState, _events: GameEvent[]): boolean {
  const profile = lengthProfile(s.config.length);
  const daysElapsed = s.clock.day - 1;

  if (s.config.victory === 'firstToTarget') {
    const target =
      s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
    const score = s.config.mode === 'coop' ? totalRevenue(s) : Math.max(...s.players.map((p) => p.revenue));
    if (score >= target) {
      const winner =
        s.config.mode === 'coop'
          ? null
          : s.players.findIndex((p) => p.revenue === score);
      s.status = 'finished';
      s.outcome = { result: 'win', winner: winner === -1 ? null : winner, reason: 'targetReached' };
      return true;
    }
    return false;
  }

  if (daysElapsed < profile.days) return false;

  s.status = 'finished';

  if (s.config.mode === 'competitive') {
    // §3.5 — all players' Revenue combined must clear the floor, or everyone loses.
    if (totalRevenue(s) < collectiveRevenueFloor(s.players.length, profile.days)) {
      s.outcome = { result: 'loss', winner: null, reason: 'revenueFloor' };
      return true;
    }
    const best = Math.max(...s.players.map((p) => p.revenue));
    s.outcome = {
      result: 'win',
      winner: s.players.findIndex((p) => p.revenue === best),
      reason: 'daysElapsed',
    };
    return true;
  }

  // Solitaire and Co-op: the target doubles as a MINIMUM. Below it you lose regardless of score.
  const target = s.config.mode === 'coop' ? profile.target * s.players.length : profile.target;
  const score = s.config.mode === 'coop' ? totalRevenue(s) : (s.players[0]?.revenue ?? 0);
  s.outcome =
    score >= target
      ? { result: 'win', winner: null, reason: 'daysElapsed' }
      : { result: 'loss', winner: null, reason: 'revenueFloor' };
  return true;
}

// ---------------------------------------------------------------------------
// Pump helper
// ---------------------------------------------------------------------------

/** Runs automatic work until a player must act or the game ends. */
export function pump(s: GameState, maxSteps = 10_000): GameEvent[] {
  const all: GameEvent[] = [];
  for (let i = 0; i < maxSteps; i++) {
    const r = advance(s);
    all.push(...r.events);
    if (r.needsInput || s.status === 'finished') return all;
  }
  throw new Error('phase driver failed to settle — probable infinite loop');
}

export { nodeIndexOfOffice };
