/**
 * Component 2 — State model and types.
 *
 * The entity model from docs/architecture/game-state.md. Pure data; no behaviour beyond a few
 * derivations that must never be cached (see DERIVED note below).
 * See architecture/components.md §2 A.2.
 */

import type {
  CarType,
  Direction,
  MainlineKind,
  FreightKind,
  GameLength,
  ModifierKind,
  OfficeTier,
  TrackGeometry,
} from './content.ts';
import { officeProfile } from './content.ts';

// ---------------------------------------------------------------------------
// Identifiers
// ---------------------------------------------------------------------------

export type PlayerIndex = number;
export type CardId = string;
export type TrayId = string;

/** A cell in a player's Office Area grid. Sparse — cards are placed during play. */
export type GridCoord = { row: number; col: number };

export function coordKey(c: GridCoord): string {
  return `${c.row},${c.col}`;
}

// ---------------------------------------------------------------------------
// Rolling stock
// ---------------------------------------------------------------------------

/** §2.2 — a coloured car is loaded, a white car is empty. */
export type RollingStock = { type: CarType; loaded: boolean };

// ---------------------------------------------------------------------------
// Track and Office Area
// ---------------------------------------------------------------------------

/**
 * A turnout's handedness: the stem is §A.1's "A", the two legs are "B" and "C". The rule that
 * matters is that `through` and `diverge` are NOT joined to each other.
 *
 * OPEN (Gap 11) — the rules specify "turnout ×6" without saying how many face which way. Until
 * that is settled, orientation is carried per card and defaults to stem-east / diverge-north.
 */
export type TurnoutOrientation = { stem: 'n' | 's' | 'e' | 'w'; through: 'n' | 's' | 'e' | 'w'; diverge: 'n' | 's' | 'e' | 'w' };

/**
 * A straight card's axis. Also part of Gap 11 — the catalogue says "straight ×3" without saying
 * how many run each way, yet a layout with no north-south straight can never use the Office's
 * junction stubs at all.
 */
export type TrackAxis = 'ew' | 'ns';

export type CardGeometry =
  | {
      kind: 'track';
      geometry: TrackGeometry;
      turnout?: TurnoutOrientation;
      axis?: TrackAxis;
      /** Curves are printed left- or right-handed (design supply); not chosen on placement. */
      hand?: 'left' | 'right';
    }
  | { kind: 'office' }
  | { kind: 'limits' }
  | { kind: 'facility'; facility: FreightKind; axis?: TrackAxis }
  /** Not track — a Modifier sits beside a Facility and raises its capacity (§9). */
  | { kind: 'modifier'; modifier: ModifierKind }
  /** Not track — a Space-use card played at a district to consume a cell (Q6). */
  | { kind: 'spaceUse'; key: string };

export type TrackCard = {
  geometry: CardGeometry;
  /**
   * DERIVED for facility cards — a Facility track locked by loads on MEN|AT|WORK stops being
   * Operational Rail (§9.3). Use isOperationalRail() rather than reading a stored flag.
   */
  baseOperationalRail: boolean;
  /** Uncoupled cars left here, in track order (§A.3). */
  standing: RollingStock[];
  facility: Facility | null;
  modifiers: ModifierKind[];
  /** Enhancement cards laid on this card (§7 of implications.md). */
  enhancements: string[];
};

export type OfficeArea = {
  owner: PlayerIndex;
  tier: OfficeTier;
  grid: Map<string, TrackCard>;
  officeCoord: GridCoord;
  /** The grid row that is the Running Track (§2.1). */
  runningRow: number;
  limitsWest: GridCoord;
  limitsEast: GridCoord;
  /** Trays holding at the Office. Length must never exceed the tier's A/D track count. */
  adOccupancy: TrayId[];
  /**
   * Trains held at the Limits by an Interlocking rather than admitted to the Office. They are
   * inside the player's Limits but not occupying an A/D track.
   */
  heldAtLimits: TrayId[];
  /** Dispatch bonuses spent this Day, by enhancement key — each is once a Day. */
  dispatchUsedToday: string[];
  /**
   * The player's personal track supply (26 pieces), keyed `geometry:hand`. Track is NOT in the
   * Home Office deck — it is laid from here.
   */
  trackSupply: Map<string, number>;
};

// ---------------------------------------------------------------------------
// Facilities
// ---------------------------------------------------------------------------

/**
 * A load in transit along the MEN | AT | WORK track (§9.3).
 *
 * Direction matters and is easy to miss. **Outbound** loading runs Green -> MEN -> AT -> WORK ->
 * onto a spotted empty car. **Inbound** unloading runs the other way: car -> WORK -> AT -> MEN ->
 * red Inbound box. Same three boxes, opposite traversal.
 */
export type Load = { type: CarType; dir: 'out' | 'in' };

export type Facility = {
  kind: 'freight' | 'passenger';
  subtype: FreightKind | 'office';
  allows: { outbound: boolean; inbound: boolean };
  outboundBox: RollingStock[];
  inboundBox: RollingStock[];
  capacity: { outbound: number; inbound: number };
  /** Freight only. One load per box; three boxes, so it is a pipeline (§9.1). */
  menAtWork: [Load | null, Load | null, Load | null];
  /** Where cars are spotted for loading and unloading. */
  industryTrack: { length: number; cars: RollingStock[] };
  laborers: number;
  porters: number;
  /** Resets at the start of each Stage (§9.1). */
  usedThisStage: { laborers: number; porters: number };
};

/**
 * §9.3 — while ANY load sits on MEN|AT|WORK the industry's track is locked down and loses its
 * Operational Rail status. This is why isOperationalRail is a function, not a stored field.
 */
/**
 * The cars physically standing on a card.
 *
 * On a Facility card the industry track IS where cars stand — spotting a car there and leaving a
 * car there are the same act (§9.3). Modelling them as two separate places was a bug: cars dropped
 * at a facility went into `standing`, while loading looked for them on `industryTrack`, so no
 * freight load could ever complete and freight revenue was structurally zero.
 */
export function carsOn(card: TrackCard): RollingStock[] {
  return card.facility && card.facility.industryTrack.length > 0
    ? card.facility.industryTrack.cars
    : card.standing;
}

/** How many more cars this card can hold. Ordinary track is unbounded; an industry track is not. */
export function spaceOn(card: TrackCard): number {
  if (card.facility && card.facility.industryTrack.length > 0) {
    return card.facility.industryTrack.length - card.facility.industryTrack.cars.length;
  }
  return Number.MAX_SAFE_INTEGER;
}

export function isOperationalRail(card: TrackCard): boolean {
  if (!card.baseOperationalRail) return false;
  if (card.facility && card.facility.menAtWork.some((slot) => slot !== null)) return false;
  return true;
}

// ---------------------------------------------------------------------------
// Trains
// ---------------------------------------------------------------------------

export type NodeRef =
  | { at: 'divisionPoint'; side: Direction }
  | { at: 'mainline'; index: number }
  | { at: 'grid'; owner: PlayerIndex; coord: GridCoord };

export type CrewTray = {
  id: TrayId;
  /** null while a local crew is switching without a train card. */
  trainNumber: number | null;
  trainIsExtra: boolean;
  /** Which end the engine occupies (§A.3). */
  engineFront: boolean;
  /** ORDERED, left-to-right. Max 4 including any caboose (§A.4). */
  consist: RollingStock[];
  direction: Direction;
  /**
   * Which way the engine points, as an actual port on the card beneath it.
   *
   * NOT derivable from `direction`, which only has east and west: a crew standing on a north-south
   * spur points north or south, and deriving 'e'/'w' gave it an exit port the card does not have —
   * so it had no legal moves at all and was stranded permanently. Left optional so a tray placed
   * without one falls back to `direction`.
   */
  facing?: 'n' | 's' | 'e' | 'w';
  position: NodeRef;
  movesUsed: number;
};

// ---------------------------------------------------------------------------
// The Division
// ---------------------------------------------------------------------------

/**
 * A train part-way across a Mainline card. Crossing time is measured in STAGES (Q1/Q2), so the
 * regions the placeholder used are gone — the cells printed on the cards are decoration.
 */
export type Transit = { tray: TrayId; stagesRemaining: number; direction: Direction };

export type DivisionNode =
  | { kind: 'divisionPoint'; side: Direction; holding: TrayId[] }
  | {
      kind: 'mainline';
      card: MainlineKind;
      transits: Transit[];
      absSignals?: boolean;
      /** Brakeman / Airbrakes / Helpers / Realignment laid on this card. */
      modifiers?: string[];
      /**
       * Which way a train is travelling when it climbs. The Heavy Grade card prints "(Up)" and
       * "Player sets orientation", so the direction is chosen when the card is placed.
       */
      gradeUp?: Direction;
      /** Red Flags protecting a stopped train here, by tray. */
      redFlagged?: TrayId[];
    }
  | { kind: 'office'; owner: PlayerIndex };

/** Ordered west to east. For N players: N Office nodes and N+1 Mainline cards. */
export type Division = { nodes: DivisionNode[] };

// ---------------------------------------------------------------------------
// Decks and yards
// ---------------------------------------------------------------------------

export type CardKind =
  | { kind: 'timetabledTrain'; number: number }
  | { kind: 'extraTrain'; number: number }
  | { kind: 'office'; tier: OfficeTier }
  | { kind: 'freightFacility'; facility: FreightKind }
  | { kind: 'modifier'; modifier: ModifierKind }
  | { kind: 'track'; geometry: TrackGeometry }
  /**
   * The four categories recovered from the design. They are in the deck so the composition is
   * right; their BEHAVIOUR is not implemented yet (implications.md §10 is still open), so
   * `check` rejects playing them and they can only be discarded.
   */
  | { kind: 'spaceUse'; key: string }
  | { kind: 'enhancement'; key: string }
  | { kind: 'mainlineModifier'; key: string }
  | { kind: 'maneuver'; key: string }
  | { kind: 'action'; key: string };

export type Card = { id: CardId; kind: CardKind };

export type Decks = {
  /** Face down. Order is SECRET — never projected to any client. */
  homeOffice: CardId[];
  /** Three face-up market slots fed from the deck (§2.6). */
  departments: (CardId | null)[];
  /** Face up, so players can audit discards (§2.6). */
  salvageYard: CardId[];
  /** Private to the owner. Max 3, or 4 with a Red Flag. */
  hands: Map<PlayerIndex, CardId[]>;
  redFlags: Map<PlayerIndex, boolean>;
};

export type Yards = {
  divisionYard: RollingStock[];
  classificationYard: RollingStock[];
};

// ---------------------------------------------------------------------------
// Clock and phases
// ---------------------------------------------------------------------------

export type Phase = 'localOps' | 'newTrain' | 'mainline' | 'loadUnload' | 'shiftChange';

/** §8.1 fourth condition — the Superintendent rules on a following train. */
export type SuperintendentClearance = {
  train: TrayId;
  occupiedBy: TrayId;
};

export type Clock = {
  day: number;
  /** 1..12 — the Pocket Watch. */
  stage: number;
  phase: Phase;
  /** Exactly one player may act at a time. Null during automatic Mainline movement. */
  currentActor: PlayerIndex | null;
  /** Interrupts the Mainline Phase to ask the Superintendent (§8.1). */
  pendingDecision: SuperintendentClearance | null;
  /**
   * The Superintendent's answer, waiting to be consumed by the train that asked. Without this the
   * driver would re-evaluate the same train and ask the same question forever.
   */
  clearanceRuling: { train: TrayId; allow: boolean } | null;
  superintendent: PlayerIndex;
  /**
   * How far round the table the current phase has got. Acting order starts at the Superintendent
   * and proceeds left (Gap 1), so `currentActor = (superintendent + actorOffset) % players`.
   * When it reaches the player count, the phase is complete.
   */
  actorOffset: number;
};

// ---------------------------------------------------------------------------
// Players and game
// ---------------------------------------------------------------------------

export type Player = {
  index: PlayerIndex;
  name: string;
  /** May go negative — a collision costs 5 (§10). */
  revenue: number;
};

export type GameMode = 'solitaire' | 'competitive' | 'coop';
export type VictoryCondition = 'firstToTarget' | 'highestAfterDays';

export type GameConfig = {
  mode: GameMode;
  victory: VictoryCondition;
  length: GameLength;
  optionalRules: {
    reducedVisibility: boolean;
    sisterTrains: boolean;
    employeeRotation: boolean;
    emergencyToolbox: boolean;
  };
};

export type OutcomeReason =
  | 'targetReached'
  | 'daysElapsed'
  | 'collisionFloor'
  | 'revenueFloor';

export type Outcome = {
  result: 'win' | 'loss';
  winner: PlayerIndex | null;
  reason: OutcomeReason;
};

/**
 * Per-Stage transient bookkeeping for the acting player. Reset when the actor changes.
 *
 * §6 — the three Local Operations options are mutually exclusive: choosing one forecloses the
 * others for that Stage. That exclusivity lives here.
 */
export type TurnState = {
  option: 'switch' | 'draw' | 'freightAgent' | null;
  movesRemaining: number;
  drawnThisTurn: boolean;
  /**
   * Track was a CARD before it became a per-player supply, so it was naturally limited to one play
   * a turn. The supply has 26 pieces and nothing else bounds it, so keep that limit explicitly.
   */
  laidThisTurn: boolean;
  freightAgentUsed: boolean;
  /** Set when the actor finishes; the phase driver then moves to the next player. */
  done: boolean;
};

export function freshTurn(moves: number): TurnState {
  return {
    option: null,
    movesRemaining: moves,
    drawnThisTurn: false,
    laidThisTurn: false,
    freightAgentUsed: false,
    done: false,
  };
}

export type GameState = {
  id: string;
  config: GameConfig;
  /** All RNG derives from this. Games are exactly replayable. */
  seed: number;
  rngState: number;
  players: Player[];
  division: Division;
  officeAreas: Map<PlayerIndex, OfficeArea>;
  trays: Map<TrayId, CrewTray>;
  /** Trays not yet in play; §7 scarcity is an explicit mechanic. */
  freeTrays: TrayId[];
  cards: Map<CardId, Card>;
  decks: Decks;
  yards: Yards;
  /** Index 0 = Stage 1. A train number, or null for an empty slot. */
  timetable: (number | null)[];
  /**
   * §7 — Extra Trains played from hand, waiting for a free Crew Tray. An Extra is not scheduled:
   * it runs once, immediately, then its card goes to the Salvage Yard (§2.3).
   */
  pendingExtras: number[];
  /**
   * Q9 — train numbers ordered to run a second section. The next New Train Phase makes up an
   * identical train behind the first, if a Crew Tray is free.
   */
  pendingSecondSections: number[];
  clock: Clock;
  turn: TurnState;
  /** Transient: trains already moved in the current Mainline Phase. Cleared when it ends. */
  movedThisPhase: Set<TrayId>;
  /** §3.4 — resets at the start of each Day. */
  collisionsToday: number;
  status: 'setup' | 'active' | 'finished';
  outcome: Outcome | null;
};

// ---------------------------------------------------------------------------
// Derivations — never stored, never cached
// ---------------------------------------------------------------------------

/**
 * A Subdivision is the Mainline track between the Limits of opposing Control Points (§2.1).
 * Whistle Posts are NOT Control Points and sit inside a Subdivision like ordinary mainline.
 *
 * At game start every Office is a Whistle Post, so the entire railroad is ONE Subdivision (§8) —
 * which is why early traffic is so constrained. Each Office upgrade splits one in two.
 *
 * Recomputed on demand. Caching this means every Office upgrade must remember to invalidate,
 * and forgetting is a silent bug in highball legality.
 */
export function subdivisions(state: GameState): number[][] {
  const out: number[][] = [];
  let current: number[] = [];

  state.division.nodes.forEach((node, i) => {
    const isBoundary =
      node.kind === 'divisionPoint' ||
      (node.kind === 'office' && isControlPoint(state, node.owner));

    if (isBoundary) {
      if (current.length > 0) out.push(current);
      current = [];
    } else {
      current.push(i);
    }
  });

  if (current.length > 0) out.push(current);
  return out;
}

export function isControlPoint(state: GameState, owner: PlayerIndex): boolean {
  const area = state.officeAreas.get(owner);
  if (!area) throw new Error(`no Office Area for player ${owner}`);
  return officeProfile(area.tier).isControlPoint;
}

export function adTrackCount(state: GameState, owner: PlayerIndex): number {
  const area = state.officeAreas.get(owner);
  if (!area) throw new Error(`no Office Area for player ${owner}`);
  return officeProfile(area.tier).adTracks;
}

export function totalRevenue(state: GameState): number {
  return state.players.reduce((n, p) => n + p.revenue, 0);
}
