/**
 * Component 6 — Legal-action enumeration.
 *
 * `legalActions(state, actor) -> Intent[]` — what the UI greys out, and what a bot picks from.
 * See architecture/components.md §2 A.6.
 *
 * THE DISCIPLINE. This module enumerates *candidate* intents and then filters them through
 * component 4's `check`. It contains no rules of its own. That is deliberate: two independent
 * implementations of turnout directionality or the four-slot limit would eventually disagree, and
 * the failure mode is a legal move the UI refuses or an illegal one it offers.
 *
 * If you find yourself writing a rule here, it belongs in apply.ts.
 */

import type { CarType, Hand, TrackGeometry } from './content.ts';
import { check, areaOf, destinationsFor } from './apply.ts';
import type { Intent } from './intents.ts';
import type { GameState, GridCoord, PlayerIndex } from './state.ts';

const CAR_TYPES: readonly CarType[] = ['coach', 'boxcar', 'reefer', 'hopper', 'tank', 'caboose'];

/** Every intent `player` may legally submit right now. */
export function legalActions(s: GameState, player: PlayerIndex): Intent[] {
  return candidates(s, player).filter((i) => check(s, player, i) === null);
}

export function isLegal(s: GameState, player: PlayerIndex, i: Intent): boolean {
  return check(s, player, i) === null;
}

/**
 * Candidate generation. Over-generates freely — `check` is the authority, so a candidate that
 * turns out to be illegal simply gets filtered. Being generous here is what stops this module
 * from quietly acquiring rules.
 */
function candidates(s: GameState, player: PlayerIndex): Intent[] {
  const out: Intent[] = [];

  // The clearance ruling arrives out of turn order and goes to the Superintendent (§8.1).
  if (s.clock.pendingDecision !== null) {
    out.push({ type: 'mainline.clearance', allow: true });
    out.push({ type: 'mainline.clearance', allow: false });
  }

  switch (s.clock.phase) {
    case 'localOps':
      out.push(...localOpsCandidates(s, player));
      break;
    case 'newTrain':
      out.push(...newTrainCandidates(s));
      break;
    case 'loadUnload':
      out.push(...loadUnloadCandidates(s, player));
      break;
    default:
      break;
  }

  // Red Flags — "any time", so they are candidates in every phase, on any train standing out on
  // the Mainline (the player's own or another's: protecting a train is not an attack).
  for (const cardId of s.decks.hands.get(player) ?? []) {
    const k = s.cards.get(cardId)?.kind;
    if (k?.kind !== 'maneuver' || k.key !== 'redFlags') continue;
    for (const [trayId] of s.trays) out.push({ type: 'maneuver.redFlags', cardId, trayId });
  }

  out.push({ type: 'redFlag.play' });
  return out;
}

function localOpsCandidates(s: GameState, player: PlayerIndex): Intent[] {
  const out: Intent[] = [];

  // §6 — the three-way exclusive choice.
  out.push({ type: 'localOps.choose', option: 'switch' });
  out.push({ type: 'localOps.choose', option: 'draw' });
  out.push({ type: 'localOps.choose', option: 'freightAgent' });

  const area = areaOf(s, player);

  // -- switch (§6.1)
  for (const [trayId, tray] of s.trays) {
    if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
    const from = tray.position.coord;
    for (const reverse of [false, true]) {
      for (const d of destinationsFor(s, player, trayId, from, reverse)) {
        out.push({ type: 'switch.move', trayId, to: d.coord, reverse });
      }
    }
    for (let n = 1; n <= tray.consist.length; n++) {
      out.push({ type: 'switch.dropCars', trayId, count: n });
    }
    // Small Yard: enumerating every permutation would explode, so offer the useful ones —
    // bringing each car to the droppable end, plus a full reversal. `check` validates any order,
    // so a UI may submit an arbitrary permutation.
    const n = tray.consist.length;
    if (n > 1) {
      for (let k = 0; k < n; k++) {
        const order = [...Array(n).keys()].filter((x) => x !== k);
        order.push(k);
        out.push({ type: 'switch.sortConsist', trayId, order });
      }
      out.push({ type: 'switch.sortConsist', trayId, order: [...Array(n).keys()].reverse() });
    }
  }
  // Flying Switch — roll a cut into an ADJACENT industry without the engine entering it.
  for (const cardId of s.decks.hands.get(player) ?? []) {
    const k = s.cards.get(cardId)?.kind;
    if (k?.kind !== 'maneuver' || k.key !== 'flyingSwitch') continue;
    for (const [trayId, tray] of s.trays) {
      if (tray.position.at !== 'grid' || tray.position.owner !== player) continue;
      const from = tray.position.coord;
      for (const reverse of [false, true]) {
        for (const d of destinationsFor(s, player, trayId, from, reverse)) {
          for (let count = 1; count <= tray.consist.length; count++) {
            out.push({ type: 'maneuver.flyingSwitch', cardId, trayId, count, to: d.coord });
          }
        }
      }
    }
  }
  out.push({ type: 'switch.end' });

  // -- draw (§6.2)
  out.push({ type: 'draw.fromHomeOffice' });
  for (let slot = 0; slot < 3; slot++) out.push({ type: 'draw.fromDepartment', slot });

  const placements = placementCandidates(s, player);
  // Enhancements ATTACH to a card already in the grid, so their candidates are the occupied cells,
  // not the empty ones every other placeable card wants. Offering them `placements` meant an
  // Enhancement was never once legal on a real card — 18 of 93 solitaire cards, permanently dead.
  const attachments = [...area.grid.keys()].map((k) => {
    const [row, col] = k.split(',').map(Number);
    return { row: row!, col: col! };
  });
  for (const cardId of s.decks.hands.get(player) ?? []) {
    out.push({ type: 'card.play', cardId });
    // Gap 11 — orientation is chosen on placement, so every rotation is a distinct candidate.
    // Six is the widest set (turnouts); `check` discards the ones whose ports do not meet.
    const targets = s.cards.get(cardId)?.kind.kind === 'enhancement' ? attachments : placements;
    for (const placement of targets) {
      for (let variant = 0; variant < 6; variant++) {
        out.push({ type: 'card.play', cardId, placement, variant });
      }
    }
    for (let slot = 0; slot < 3; slot++) out.push({ type: 'card.discard', cardId, toSlot: slot });
  }
  // Lay track from the personal supply — the only way a district grows now that track is not in
  // the deck.
  for (const [key, count] of s.turn.laidThisTurn ? [] : area.trackSupply) {
    if (count < 1) continue;
    const [geometry, hand] = key.split(':') as [TrackGeometry, Hand];
    for (const placement of placements) {
      for (let variant = 0; variant < 2; variant++) {
        out.push({ type: 'track.lay', geometry, hand, placement, variant });
      }
    }
  }
  // Mainline modifiers go on a Mainline card, not a grid cell, so `node` indexes division.nodes.
  for (const cardId of s.decks.hands.get(player) ?? []) {
    if (s.cards.get(cardId)?.kind.kind !== 'mainlineModifier') continue;
    for (let node = 0; node < s.division.nodes.length; node++) {
      out.push({ type: 'mainline.modify', cardId, node });
    }
  }
  out.push({ type: 'draw.end' });

  // -- freight agent (§6.3)
  for (const coord of facilityCoords(s, player)) {
    for (const carType of CAR_TYPES) {
      out.push({ type: 'freightAgent.stockOutbound', at: coord, carType });
    }
    const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
    if (f) {
      for (let idx = 0; idx < f.inboundBox.length; idx++) {
        out.push({ type: 'freightAgent.clearInbound', at: coord, index: idx });
      }
      for (const from of ['outbound', 'inbound', 'menAtWork'] as const) {
        for (let idx = 0; idx < 3; idx++) {
          out.push({ type: 'freightAgent.unjam', at: coord, from, index: idx });
        }
      }
    }
  }

  return out;
}

function newTrainCandidates(s: GameState): Intent[] {
  const out: Intent[] = [];
  for (const [trayId] of s.trays) {
    for (const carType of CAR_TYPES) {
      out.push({ type: 'newTrain.placeCar', trayId, carType, loaded: true });
      out.push({ type: 'newTrain.placeCar', trayId, carType, loaded: false });
    }
    out.push({ type: 'newTrain.passCar', trayId });
  }
  const due = s.timetable[s.clock.stage - 1];
  if (due !== null && due !== undefined) {
    out.push({ type: 'newTrain.secondSection', trainNumber: due });
  }
  return out;
}

function loadUnloadCandidates(s: GameState, player: PlayerIndex): Intent[] {
  const out: Intent[] = [];
  const area = areaOf(s, player);

  for (const coord of facilityCoords(s, player)) {
    out.push({ type: 'porter.board', at: coord });
    out.push({ type: 'porter.detrain', at: coord });
    const f = area.grid.get(`${coord.row},${coord.col}`)?.facility;
    if (f) {
      out.push({ type: 'laborer.startLoad', at: coord });
      for (let box = 0; box < f.menAtWork.length; box++) {
        out.push({ type: 'laborer.advanceLoad', at: coord, box });
      }
      for (let ci = 0; ci < f.industryTrack.cars.length; ci++) {
        out.push({ type: 'laborer.beginUnload', at: coord, carIndex: ci });
      }
    }
  }
  out.push({ type: 'loadUnload.end' });
  return out;
}

function facilityCoords(s: GameState, player: PlayerIndex): GridCoord[] {
  const out: GridCoord[] = [];
  for (const [key, card] of areaOf(s, player).grid) {
    if (!card.facility) continue;
    const [row, col] = key.split(',').map(Number);
    out.push({ row: row!, col: col! });
  }
  return out;
}

/** Empty cells adjacent to occupied ones — `check` decides which actually connect. */
function placementCandidates(s: GameState, player: PlayerIndex): GridCoord[] {
  const area = areaOf(s, player);
  const seen = new Set<string>();
  const out: GridCoord[] = [];

  for (const key of area.grid.keys()) {
    const [row, col] = key.split(',').map(Number);
    const around: GridCoord[] = [
      { row: row! + 1, col: col! },
      { row: row! - 1, col: col! },
      { row: row!, col: col! + 1 },
      { row: row!, col: col! - 1 },
    ];
    for (const c of around) {
      // Q7 — a district hangs BELOW the Running Track; there is nothing above it.
      if (c.row > area.runningRow) continue;
      const k = `${c.row},${c.col}`;
      if (seen.has(k)) continue;
      // The Limits signs are candidates even though they are occupied: laying track there is how
      // the Running Track grows, and the sign moves outward (§2.1). `check` still has the final say.
      const occupied = area.grid.get(k);
      if (occupied && !(occupied.geometry.kind === 'limits' && c.row === area.runningRow)) continue;
      seen.add(k);
      out.push(c);
    }
  }
  return out;
}
