/**
 * Component 3 — Track graph and movement.
 *
 * The Office Area grid as a traversable graph, and the rules governing a Move.
 * See architecture/components.md §2 A.3 and docs/rules/rules-v0.2.md Appendix A.
 *
 * THE CENTRAL SUBTLETY. §A.1 says a train entering a turnout from "A" may proceed to B or C, but
 * one entering through B or C may only proceed to A. That is NOT a one-way restriction — A→B and
 * B→A are both legal. What it means is that **B and C are not connected to each other**: the frog
 * offers no route between the two diverging legs. Modelling this as a directed graph would forbid
 * legal moves. It is an undirected graph over *port pairs*, and the constraint is which pairs
 * exist on each card.
 *
 * The second constraint is that a traversal may never leave a card through the port it entered by.
 * That is what "without changing direction" (§2.4) means in practice.
 */

import { MAX_CONSIST } from './content.ts';
import type { TrackGeometry } from './content.ts';
import type {
  GridCoord,
  OfficeArea,
  RollingStock,
  TrackCard,
  TrayId,
  TurnoutOrientation,
} from './state.ts';
import { carsOn, coordKey, isOperationalRail, spaceOn } from './state.ts';

// ---------------------------------------------------------------------------
// Ports and geometry
// ---------------------------------------------------------------------------

/** The four edges of a card. East is the player's right, west their left (§2.4). */
export type Port = 'n' | 's' | 'e' | 'w';

export function opposite(p: Port): Port {
  switch (p) {
    case 'n':
      return 's';
    case 's':
      return 'n';
    case 'e':
      return 'w';
    case 'w':
      return 'e';
  }
}

/** The grid neighbour across a given edge. Row increases northward. */
export function neighbour(c: GridCoord, p: Port): GridCoord {
  switch (p) {
    case 'n':
      return { row: c.row + 1, col: c.col };
    case 's':
      return { row: c.row - 1, col: c.col };
    case 'e':
      return { row: c.row, col: c.col + 1 };
    case 'w':
      return { row: c.row, col: c.col - 1 };
  }
}

type PortPair = readonly [Port, Port];

/**
 * Which ports a card joins internally.
 *
 * - **straight / limits** — a plain through track.
 * - **turnout** — the through track plus ONE diverging leg off the east end. `e-w` and `e-n` exist;
 *   `w-n` deliberately does not. That absence is §A.1's rule.
 * - **runAround** — a double-ended siding: both ends reach the loop, so a train can pass around
 *   standing cars. §A.5's facing-point move is impossible without one.
 * - **office** — Gap 8: junction stubs above and below, each reaching both ends of the through
 *   track. These are plain junctions, so unlike a turnout there is no missing pair. North and south
 *   are not joined to each other — that would be crossing the running track.
 * - **facility** — a through track; the industry spur is the card's own spotting capacity rather
 *   than a separate port.
 */
function connectionsFor(card: TrackCard): readonly PortPair[] {
  switch (card.geometry.kind) {
    // Neither a Modifier nor a Space-use card is track: nothing connects, no train may enter.
    case 'modifier':
    case 'spaceUse':
      return [];
    case 'limits':
      return [['e', 'w']];
    case 'facility':
      return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
    // Q7 — the card art draws the through-track along the TOP edge with everything diverging
    // downward, so a district is a Running Track with all Secondary Track hanging beneath it.
    // This supersedes Gap 8's north-and-south stubs.
    case 'office':
      return [
        ['e', 'w'],
        ['e', 's'],
        ['w', 's'],
      ];
    case 'track':
      switch (card.geometry.geometry) {
        case 'straight':
          return card.geometry.axis === 'ns' ? [['n', 's']] : [['e', 'w']];
        case 'turnout': {
          const o = card.geometry.turnout ?? DEFAULT_TURNOUT;
          // stem-through and stem-diverge exist; through-diverge deliberately does not (§A.1).
          return [
            [o.stem, o.through],
            [o.stem, o.diverge],
          ];
        }
        // A CURVE joins the through track to one diverging side — handedness is printed on the
        // card, not chosen (the design supplies both hands). A SHARP curve is geometrically the
        // same but costs two Moves to cross.
        // Curves and turnouts diverge DOWNWARD from the through track (Q7); handedness decides
        // which end of the through track the leg leaves from, not whether it goes up or down.
        case 'curved':
        case 'sharpCurved': {
          const stem = card.geometry.hand === 'left' ? 'w' : 'e';
          return [
            ['e', 'w'],
            [stem, 's'],
          ];
        }
      }
  }
}

/** A turnout with no stated orientation takes this one. */
export const DEFAULT_TURNOUT: TurnoutOrientation = { stem: 'e', through: 'w', diverge: 'n' };

// ---------------------------------------------------------------------------
// Orientation (Gap 11)
// ---------------------------------------------------------------------------

/**
 * How a track card may be laid. Gap 11 resolved in favour of **orientation chosen on placement**:
 * a track card is generic and the player decides how to lay it.
 *
 * The measurement that settled it: with orientation fixed, an east-west straight has no north or
 * south port, so it could never attach to the Office's junction stubs. Simulated Office Areas grew
 * only sideways and downward — never upward — purely as an artifact of the default. That silently
 * undid Gap 8 and put Appendix A's switching puzzle out of reach.
 *
 * §A.1's constraint is preserved: a turnout's two legs still never join each other. Only the card's
 * rotation is free.
 */
export type TrackVariant = {
  axis?: 'ew' | 'ns';
  turnout?: TurnoutOrientation;
  bypass?: Port;
};

/**
 * Q7 — everything diverges downward, so a turnout's leg is always south. Handedness (printed on
 * the card) decides which end of the through track it leaves from.
 */
const TURNOUT_VARIANTS: readonly TurnoutOrientation[] = [
  { stem: 'e', through: 'w', diverge: 's' },
  { stem: 'w', through: 'e', diverge: 's' },
];

export function variantsFor(geometry: TrackGeometry): TrackVariant[] {
  switch (geometry) {
    case 'straight':
      return [{ axis: 'ew' }, { axis: 'ns' }];
    case 'turnout':
      return TURNOUT_VARIANTS.map((t) => ({ turnout: t }));
    case 'curved':
    case 'sharpCurved':
      // Handedness is printed on the card, and the leg always goes down (Q7), so nothing is left
      // to choose — one variant.
      return [{ axis: 'ew' }];
  }
}

/** Facility and Office cards have fixed geometry; only plain track rotates. */
export function facilityVariants(): TrackVariant[] {
  return [{ axis: 'ew' }, { axis: 'ns' }];
}

/** Ports reachable from `from` within this card, never including `from` itself. */
export function exitsFrom(card: TrackCard, from: Port): Port[] {
  const out: Port[] = [];
  for (const [a, b] of connectionsFor(card)) {
    if (a === from && b !== from) out.push(b);
    else if (b === from && a !== from) out.push(a);
  }
  return out;
}

export function hasPort(card: TrackCard, p: Port): boolean {
  return connectionsFor(card).some(([a, b]) => a === p || b === p);
}

// ---------------------------------------------------------------------------
// Occupancy
// ---------------------------------------------------------------------------

export type Occupancy = {
  /** Which tray sits on a given card, if any. */
  trayAt(coord: GridCoord): TrayId | null;
  /** Free A/D tracks at the Office card, for the pass-through allowance (§A.4). */
  freeAdTracks(): number;
};

// ---------------------------------------------------------------------------
// Move reachability
// ---------------------------------------------------------------------------

export type MoveStep = { coord: GridCoord; entry: Port; exit: Port };

export type MoveDestination = {
  coord: GridCoord;
  /** The port the train arrived through; its new facing is the opposite. */
  entry: Port;
  path: MoveStep[];
  /** Standing cars coupled along the way, in the order encountered (§A.4). */
  couples: RollingStock[];
};

export type MoveContext = {
  area: OfficeArea;
  occupancy: Occupancy;
  /** Cars already in the tray; coupling may not push the consist past four (§A.4). */
  consistSize: number;
  /** The tray making the move, so it does not block itself. */
  self: TrayId;
};

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

function sameCoord(a: GridCoord, b: GridCoord): boolean {
  return a.row === b.row && a.col === b.col;
}

/**
 * Every card a tray may finish a single Move on, starting from `start` and leaving through
 * `initialExit`.
 *
 * A Move travels any distance without changing direction (§2.4) and must finish on Operational
 * Rail (§A.1 — a turnout carries no wheel icon, so a train may pass through but not stop). Cars
 * met along the way are coupled automatically and mandatorily; you may not go around them (§A.4).
 *
 * Direction is expressed by which port the train first leaves through. Reversing is a separate
 * Move with the opposite initial exit, which is why §A.5's worked examples spend a Move on each
 * change of direction.
 */
export function reachableDestinations(
  ctx: MoveContext,
  start: GridCoord,
  initialExit: Port,
): MoveDestination[] {
  const { area, occupancy } = ctx;
  const startCard = cardAt(area, start);
  if (!startCard) return [];
  if (!hasPort(startCard, initialExit)) return [];

  const results: MoveDestination[] = [];
  const seen = new Set<string>();

  type Frontier = { coord: GridCoord; entry: Port; path: MoveStep[]; couples: RollingStock[] };

  const first = neighbour(start, initialExit);
  const queue: Frontier[] = [
    { coord: first, entry: opposite(initialExit), path: [], couples: [] },
  ];

  while (queue.length > 0) {
    const node = queue.shift()!;
    const card = cardAt(area, node.coord);
    if (!card) continue;

    // Two trains may not share a card or move through each other (§A.4). The Office track is the
    // exception: while it has free A/D tracks you may enter and pass through.
    const occupant = occupancy.trayAt(node.coord);
    if (occupant !== null && occupant !== ctx.self) {
      const isOffice = sameCoord(node.coord, area.officeCoord);
      if (!isOffice || occupancy.freeAdTracks() <= 0) continue;
    }

    if (!hasPort(card, node.entry)) continue;

    // Mandatory coupling. Rejecting rather than truncating is deliberate: a move that would
    // overfill the tray is illegal, not a move that picks up fewer cars.
    const couples = [...node.couples, ...carsOn(card)];
    if (ctx.consistSize + couples.length > MAX_CONSIST) continue;

    const key = `${coordKey(node.coord)}|${node.entry}`;
    if (seen.has(key)) continue;
    seen.add(key);

    if (isOperationalRail(card) && !sameCoord(node.coord, start)) {
      results.push({ coord: node.coord, entry: node.entry, path: node.path, couples });
    }

    for (const exit of exitsFrom(card, node.entry)) {
      const step: MoveStep = { coord: node.coord, entry: node.entry, exit };
      queue.push({
        coord: neighbour(node.coord, exit),
        entry: opposite(exit),
        path: [...node.path, step],
        couples,
      });
    }
  }

  return results;
}

/** Both directions at once — what the UI highlights when a tray is selected. */
export function allReachable(
  ctx: MoveContext,
  start: GridCoord,
  facing: Port,
): { forward: MoveDestination[]; reverse: MoveDestination[] } {
  return {
    forward: reachableDestinations(ctx, start, facing),
    reverse: reachableDestinations(ctx, start, opposite(facing)),
  };
}

// ---------------------------------------------------------------------------
// Placement
// ---------------------------------------------------------------------------

/**
 * Gap 4a — a placed card must connect to existing track. Gap 8 gives the Office card junction
 * stubs above and below so this is satisfiable from the opening Stage.
 */
export function canPlaceAt(area: OfficeArea, coord: GridCoord, card: TrackCard): boolean {
  const existing = cardAt(area, coord);
  // A Limits sign on the Running Track is the GROWTH POINT, not an obstacle. Extending means laying
  // the card where the sign stands and moving the sign outward (§2.1, Gap 4a) — the physical act at
  // the table. Treating the sign as occupied forced players to build PAST it, stranding the sign
  // mid-track with everything beyond it nominally outside their own Limits.
  const isMovableSign =
    existing?.geometry.kind === 'limits' &&
    coord.row === area.runningRow &&
    existing.standing.length === 0;
  if (existing && !isMovableSign) return false;

  // §2.1 — the Running Track runs BETWEEN the Limits. Nothing on that row may sit outside them, so
  // the sign itself is the only growth point. Allowing a placement beyond it built track on the far
  // side of the sign and then planted a SECOND sign further out, leaving the board reading
  //   limits · Whistle Post · limits · straight · limits
  // with a Limits card stranded mid-track. The district below the Running Track is unbounded.
  if (coord.row === area.runningRow) {
    if (coord.col < area.limitsWest.col || coord.col > area.limitsEast.col) return false;
  }

  const ports: Port[] = ['n', 's', 'e', 'w'];
  for (const p of ports) {
    if (!hasPort(card, p)) continue;
    const neighbourCard = cardAt(area, neighbour(coord, p));
    if (neighbourCard && hasPort(neighbourCard, opposite(p))) return true;
  }
  return false;
}

/**
 * §A.4 — the Office track is Operational Rail, but Rolling Stock may not be left there.
 * An industry track also has a finite length (§9.3), so it can be full.
 */
export function canDropCarsAt(area: OfficeArea, coord: GridCoord, count = 1): boolean {
  if (sameCoord(coord, area.officeCoord)) return false;
  const card = cardAt(area, coord);
  if (!card || !isOperationalRail(card)) return false;
  return spaceOn(card) >= count;
}
