/**
 * Component 17 — Heuristic bot players.
 *
 * Dev-side only: this ships nowhere. Its job is to play well enough that the balance harness
 * (component 18) produces numbers worth trusting. See architecture/components.md §2 D.17.
 *
 * A bot that plays LEGALLY is easy; one that plays WELL enough to judge balance is the harder
 * half, and this is only the first attempt. Read the numbers it produces as a floor on what a
 * competent human would score, not a prediction of one.
 *
 * THE POLICY, from the economy in docs/rules/card-reference.md §7. Local Operations actions are
 * the currency — one per Stage, twelve per Day, roughly one Revenue point each — so the bot's
 * whole job is deciding which of the three options to spend the Stage on:
 *
 *   1. Develop first. A Whistle Post is not a Passenger Facility and earns nothing from
 *      passengers, so upgrading and placing freight facilities dominates early.
 *   2. Then keep the pipeline fed. Stocking a green box is what converts an action into a point.
 *   3. Switch when a train is standing at the Office, because spotted cars are what let a load
 *      complete at all.
 */

import { applyIntent, areaOf, canAdvanceLoad, facilityCarType, laborersLeft } from '../engine/apply.ts';
import { MAX_CONSIST, nextOfficeTier } from '../engine/content.ts';
import type { GameEvent } from '../engine/events.ts';
import type { Intent } from '../engine/intents.ts';
import { legalActions } from '../engine/legal.ts';
import { coordKey } from '../engine/state.ts';
import type { Facility, GameState, PlayerIndex, RollingStock } from '../engine/state.ts';

export type BotPolicy = {
  name: string;
  choose(s: GameState, player: PlayerIndex, options: Intent[]): Intent;
};

// ---------------------------------------------------------------------------

function pickFirst(options: Intent[], ...types: Intent['type'][]): Intent | null {
  for (const t of types) {
    const found = options.find((i) => i.type === t);
    if (found) return found;
  }
  return null;
}

/**
 * §8.1 — the Superintendent's judgment call, and the bot's most consequential decision.
 *
 * Denying costs the following train one Stage. Allowing risks a collision at −5 Revenue, roughly a
 * full Day's earnings, plus a train destroyed. At ~0.5 Revenue per Stage the arithmetic favours
 * caution heavily, so this bot always denies. That is a deliberate policy choice, not an oversight:
 * a bolder policy is worth simulating separately to see what following moves are actually worth.
 */
function ruleOnClearance(options: Intent[]): Intent | null {
  return options.find((i) => i.type === 'mainline.clearance' && i.allow === false) ?? null;
}

// ---------------------------------------------------------------------------

export const developerBot: BotPolicy = {
  name: 'developer',

  choose(s, player, options) {
    const clearance = ruleOnClearance(options);
    if (clearance) return clearance;

    // --- Load/Unload: spend every worker, then end. Each is a point, or a step toward one.
    //
    // Order matters. A Porter earns a point in ONE action (§9.2), so it is always the best use of
    // a worker. Then advance loads already in the pipeline — finishing beats starting, because a
    // load parked on MEN|AT|WORK also locks the industry track (§9.3). Only then feed new work in.
    if (s.clock.phase === 'loadUnload') {
      const work = pickFirst(
        options,
        'porter.board',
        'porter.detrain',
        'laborer.advanceLoad',
        'laborer.startLoad',
        'laborer.beginUnload',
      );
      return work ?? options.find((i) => i.type === 'loadUnload.end') ?? options[0]!;
    }

    // --- New Train: fill the consist, but with the RIGHT cars.
    //
    // This is subtler than it looks. §9.3 requires an EMPTY car of the matching type spotted on an
    // outbound facility's track before a load can be worked onto it, and a LOADED car before an
    // inbound facility can unload one. So the useful car to put on a train is the one this
    // player's facilities are short of — not simply a loaded one.
    if (s.clock.phase === 'newTrain') {
      const wanted = wantedCars(s, player);
      for (const w of wanted) {
        const match = options.find(
          (i) => i.type === 'newTrain.placeCar' && i.carType === w.type && i.loaded === w.loaded,
        );
        if (match) return match;
      }
      return pickFirst(options, 'newTrain.placeCar', 'newTrain.passCar') ?? options[0]!;
    }

    // --- Local Operations.
    if (s.clock.phase === 'localOps') {
      if (s.turn.option === null) return chooseLocalOption(s, player, options);
      return followThrough(s, player, options);
    }

    return options[0]!;
  },
};

/**
 * The one decision that matters: which of §6's three things to spend this Stage on.
 *
 * The ordering below is the result of measurement, not intuition. An earlier version picked
 * Freight Agent whenever it was available, which is *always* once a facility exists with box room.
 * It therefore stopped drawing after roughly two Days, scheduled only 3 of a possible 12 trains,
 * and spent the rest of the game stuffing green boxes whose loads could never complete for want of
 * a car to load them onto.
 */
function chooseLocalOption(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
  const choices = options.filter(
    (i): i is Extract<Intent, { type: 'localOps.choose' }> => i.type === 'localOps.choose',
  );
  const can = (o: 'switch' | 'draw' | 'freightAgent') => choices.find((c) => c.option === o);

  const area = areaOf(s, player);
  const hand = s.decks.hands.get(player) ?? [];

  // 1. Trains first, always. A scheduled train runs EVERY Day thereafter, so it is the only card
  //    whose value compounds. Playing one needs the draw option.
  if (can('draw') && hand.some((id) => isTrainCard(s, id))) return can('draw')!;

  // 2. A train standing at the Office is a fleeting chance to spot cars — but ONLY if there is
  //    actually a car to spot or collect. Choosing to switch merely because a train is present
  //    wasted the whole Local Operations action shuttling back and forth: a passenger train needs
  //    no switching at all, because §9.2 works coaches straight off the A/D track.
  if (can('switch') && area.adOccupancy.length > 0 && usefulSwitching(s, player)) {
    return can('switch')!;
  }

  // 3. Stock a green box only when the load can actually finish — an empty car of the right type
  //    is already spotted. Stocking without one just fills the box.
  if (can('freightAgent') && canStockProductively(s, player)) return can('freightAgent')!;

  // 4. Rescue a jammed facility, or clear a full red box blocking further unloading.
  if (can('freightAgent') && (hasStuckLoad(s, player) || needsClearing(s, player))) {
    return can('freightAgent')!;
  }

  // 5. Otherwise develop. More facilities and a bigger Office are what make later Stages pay.
  if (can('draw')) return can('draw')!;
  return can('freightAgent') ?? can('switch') ?? choices[0] ?? options[0]!;
}

/** A face-up card worth spending the draw on rather than gambling on the deck. */
function isWorthTaking(s: GameState, slot: number): boolean {
  const id = s.decks.departments[slot];
  if (!id) return false;
  const k = s.cards.get(id)?.kind;
  if (!k) return false;
  if (k.kind === 'timetabledTrain' || k.kind === 'extraTrain') return true;
  if (k.kind === 'freightFacility') return true;
  if (k.kind === 'office') return nextOfficeTier(areaOf(s, 0).tier) === k.tier;
  return false;
}

function isTrainCard(s: GameState, cardId: string): boolean {
  const k = s.cards.get(cardId)?.kind.kind;
  return k === 'timetabledTrain' || k === 'extraTrain';
}

/**
 * A facility with room in its green box AND an empty car OF ITS OWN TYPE already spotted.
 *
 * The type match matters: a load that reaches WORK with no matching car to go onto is stuck, and a
 * stuck load strips the industry track of Operational Rail status (§9.3), so no car can be brought
 * in to rescue it. Stocking speculatively jams the facility.
 */
function canStockProductively(s: GameState, player: PlayerIndex): boolean {
  for (const card of areaOf(s, player).grid.values()) {
    const f = card.facility;
    if (!f || !f.allows.outbound) continue;
    if (f.outboundBox.length >= f.capacity.outbound) continue;

    // A PASSENGER facility has no industry track — passengers board straight off the platform
    // (§9.2). Requiring a spotted car here meant the Office's green slot was never stocked and
    // passengers essentially never boarded: one boarding in a whole 5-Day game.
    if (f.kind === 'passenger') return true;

    const want = facilityCarType(f);
    if (f.industryTrack.cars.some((c) => !c.loaded && c.type === want)) return true;
  }
  return false;
}

/** A load on MEN|AT|WORK that cannot advance, with Laborers free to move it. */
function hasStuckLoad(s: GameState, player: PlayerIndex): boolean {
  for (const card of areaOf(s, player).grid.values()) {
    const f = card.facility;
    if (!f || laborersLeft(f) < 1) continue;
    for (let box = 0; box < f.menAtWork.length; box++) {
      if (f.menAtWork[box] && !canAdvanceLoad(f, box)) return true;
    }
  }
  return false;
}

function needsClearing(s: GameState, player: PlayerIndex): boolean {
  for (const card of areaOf(s, player).grid.values()) {
    const f = card.facility;
    if (!f) continue;
    if (f.inboundBox.length >= Math.max(1, f.capacity.inbound)) return true;
  }
  return false;
}

/** Once an option is chosen, work it to a sensible conclusion. */
function followThrough(s: GameState, player: PlayerIndex, options: Intent[]): Intent {
  switch (s.turn.option) {
    case 'draw': {
      // Draw before playing — otherwise the hand empties and never refills.
      //
      // Prefer a face-up Department card only when it is actually worth having. Taking the visible
      // card unconditionally meant the bot never once drew blind from the deck across 60 games,
      // which the anomaly detector correctly flagged: a whole branch of §6.2 going unexercised.
      if (!s.turn.drawnThisTurn) {
        const useful = options.find(
          (i) => i.type === 'draw.fromDepartment' && isWorthTaking(s, i.slot),
        );
        if (useful) return useful;
        const blind = options.find((i) => i.type === 'draw.fromHomeOffice');
        if (blind) return blind;
        const any = options.find((i) => i.type === 'draw.fromDepartment');
        if (any) return any;
      }
      // Train cards first — they take no placement and their value compounds every Day.
      const train = options.find(
        (i) => i.type === 'card.play' && i.placement === undefined && isTrainCard(s, i.cardId),
      );
      if (train) return train;

      // Then real development: a card actually laid into the grid.
      const placed = options.find((i) => i.type === 'card.play' && i.placement !== undefined);
      if (placed) return placed;
      const play = options.find((i) => i.type === 'card.play');
      if (play) return play;
      const end = options.find((i) => i.type === 'draw.end');
      if (end) return end;
      return pickFirst(options, 'card.discard') ?? options[0]!;
    }

    case 'switch': {
      // Re-check every Move, not just when choosing the option. Conditions change mid-turn — a car
      // gets coupled, a siding fills — and once there is nothing left to do the fall-through would
      // pick an arbitrary legal move and burn the remaining Moves shuttling.
      if (!usefulSwitching(s, player)) {
        const stop = options.find((i) => i.type === 'switch.end');
        if (stop) return stop;
      }

      // Spotting the RIGHT car at the RIGHT facility is the whole point of switching.
      //
      // Measured: an earlier version dropped whichever car happened to be at the tray's end,
      // which silted industry tracks up with coaches and cabooses — 532 coaches were observed
      // sitting on freight sidings across 20 games. A facility whose track is full of the wrong
      // commodity cannot accept the car it actually needs, and the freight chain starves.
      //
      // §A.3 forces the issue: cars come off in seated order, so only the end car is droppable.
      // A strong player would use the six Moves to re-order the consist — that is the game's
      // central switching puzzle, and this bot does not attempt it. It simply declines to drop a
      // car that would do no work.
      const tray = trayOf(s, player);
      const here = trayLocation(s, player);
      const endCar = tray && tray.consist.length > 0 ? tray.consist[tray.consist.length - 1]! : null;

      if (endCar && here) {
        const hereFacility = areaOf(s, player).grid.get(`${here.row},${here.col}`)?.facility ?? null;
        if (hereFacility && facilityWants(hereFacility, endCar)) {
          const drop = options.find((i) => i.type === 'switch.dropCars' && i.count === 1);
          if (drop) return drop;
        }

        // Otherwise head for a facility that does want this particular car.
        const wanted = facilitiesWanting(s, player, endCar);
        const toward = options.find(
          (i) =>
            i.type === 'switch.move' &&
            wanted.some((t) => t.row === i.to.row && t.col === i.to.col),
        );
        if (toward) return toward;
      }

      const move = options.find((i) => i.type === 'switch.move');
      if (move) return move;
      return options.find((i) => i.type === 'switch.end') ?? options[0]!;
    }

    case 'freightAgent': {
      // A stuck load is the worst state a facility can be in: it blocks the pipeline AND strips
      // the industry track of Operational Rail status (§9.3), so no car can be brought in to
      // rescue it. §6.3's unjam exists for exactly this. Clear it before anything else.
      if (hasStuckLoad(s, player)) {
        const unjam = options.find((i) => i.type === 'freightAgent.unjam' && i.from === 'menAtWork');
        if (unjam) return unjam;
      }
      const clear = options.find((i) => i.type === 'freightAgent.clearInbound');
      if (clear) return clear;
      const stock = options.find((i) => i.type === 'freightAgent.stockOutbound');
      if (stock) return stock;
      return pickFirst(options, 'freightAgent.unjam') ?? options[0]!;
    }

    default:
      return options[0]!;
  }
}

// ---------------------------------------------------------------------------
// What this player's facilities are actually short of
// ---------------------------------------------------------------------------

type WantedCar = { type: string; loaded: boolean };

/**
 * An outbound facility needs EMPTY cars of its type to load onto; an inbound one needs LOADED cars
 * to unload. Ordered so the most useful car comes first.
 */
function wantedCars(s: GameState, player: PlayerIndex): WantedCar[] {
  const out: WantedCar[] = [];
  for (const card of areaOf(s, player).grid.values()) {
    const f = card.facility;
    if (!f || f.kind !== 'freight') continue;
    const spotted = f.industryTrack.cars.length;
    if (spotted >= f.industryTrack.length) continue;
    const type = carTypeOf(card);
    if (!type) continue;
    if (f.allows.outbound) out.push({ type, loaded: false });
    if (f.allows.inbound) out.push({ type, loaded: true });
  }
  // Coaches are worth carrying in BOTH states, and this is easy to get wrong. An EMPTY coach is
  // what a boarding passenger is put into (§9.2); a LOADED one carries arrivals who can be
  // de-trained. Only ever loading empty coaches means no train ever brings anyone to de-train —
  // which is exactly what happened: zero de-trainings across a whole game.
  const office = areaOf(s, player).grid.get(coordKey(areaOf(s, player).officeCoord))?.facility;
  if (office?.kind === 'passenger' && office.porters > 0) {
    const arrivalsWaiting = office.inboundBox.length < office.capacity.inbound;
    if (arrivalsWaiting) out.push({ type: 'coach', loaded: true });
  }
  out.push({ type: 'coach', loaded: false });
  return out;
}

function carTypeOf(card: { geometry: { kind: string; facility?: string } }): string | null {
  if (card.geometry.kind !== 'facility') return null;
  switch (card.geometry.facility) {
    case 'mineTipple':
    case 'powerPlant':
      return 'hopper';
    case 'produceShed':
      return 'reefer';
    case 'grocersWarehouse':
      return 'boxcar';
    case 'oilRefinery':
      return 'tank';
    default:
      return null;
  }
}

/**
 * Would spotting this car here do any work?
 *
 * An OUTBOUND facility needs an EMPTY car of its own commodity to load onto; an INBOUND one needs
 * a LOADED car of its commodity to unload. Anything else merely consumes a slot on a finite
 * industry track (§9.3).
 */
function facilityWants(f: Facility, car: RollingStock): boolean {
  if (f.kind !== 'freight') return false;
  if (f.industryTrack.cars.length >= f.industryTrack.length) return false;
  if (car.type !== facilityCarType(f)) return false;
  if (!car.loaded && f.allows.outbound) return true;
  if (car.loaded && f.allows.inbound) return true;
  return false;
}

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

/**
 * Is there any point switching this Stage?
 *
 * True when the crew is carrying a car some facility wants, or a siding holds a car that ought to
 * be hauled away. False for a pure passenger train, which needs no switching at all.
 */
function usefulSwitching(s: GameState, player: PlayerIndex): boolean {
  const tray = trayOf(s, player);
  if (!tray) return false;
  for (const car of tray.consist) {
    if (facilitiesWanting(s, player, car).length > 0) return true;
  }
  if (sidingsWorthCollecting(s, player).length > 0) return true;

  // A car left standing on ordinary track is also worth lifting if some facility wants it — the
  // hopper stranded on the Running Track that the crew kept driving past.
  if (tray.consist.length < MAX_CONSIST) {
    for (const card of areaOf(s, player).grid.values()) {
      for (const car of card.standing) {
        if (facilitiesWanting(s, player, car).length > 0) return true;
      }
    }
  }
  return false;
}

/**
 * Sidings holding a car the train should lift: a finished (loaded) car at an outbound facility, or
 * an emptied car at an inbound one. Leaving them there fills the industry track and jams the
 * facility — the "industry track full" impediment.
 */
function sidingsWorthCollecting(
  s: GameState,
  player: PlayerIndex,
): { row: number; col: number }[] {
  const out: { row: number; col: number }[] = [];
  const tray = trayOf(s, player);
  if (!tray || tray.consist.length >= MAX_CONSIST) return out;

  for (const [key, card] of areaOf(s, player).grid) {
    const f = card.facility;
    if (!f || f.kind !== 'freight') continue;
    // Skip both-direction facilities: a loaded car sitting there might be a finished outbound load
    // OR an inbound one still waiting to be unloaded, and lifting the latter undoes the delivery.
    if (f.allows.outbound && f.allows.inbound) continue;
    const done = f.industryTrack.cars.some((c) => (f.allows.outbound ? c.loaded : !c.loaded));
    if (!done) continue;
    const [row, col] = key.split(',').map(Number);
    out.push({ row: row!, col: col! });
  }
  return out;
}

/** Cars standing on ordinary track that some facility would actually take. */
function strandedWantedCars(s: GameState, player: PlayerIndex): { row: number; col: number }[] {
  const out: { row: number; col: number }[] = [];
  const tray = trayOf(s, player);
  if (!tray || tray.consist.length >= MAX_CONSIST) return out;
  for (const [key, card] of areaOf(s, player).grid) {
    if (card.facility || card.standing.length === 0) continue;
    if (!card.standing.some((c) => facilitiesWanting(s, player, c).length > 0)) continue;
    const [row, col] = key.split(',').map(Number);
    out.push({ row: row!, col: col! });
  }
  return out;
}

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

function trayLocation(s: GameState, player: PlayerIndex): { row: number; col: number } | null {
  for (const tray of s.trays.values()) {
    if (tray.position.at === 'grid' && tray.position.owner === player) {
      return tray.position.coord;
    }
  }
  return null;
}

// ---------------------------------------------------------------------------

/** Picks uniformly at random. A control, to show what the policy above is actually worth. */
export function randomBot(seed: number): BotPolicy {
  let rng = seed >>> 0;
  return {
    name: 'random',
    choose(_s, _p, options) {
      rng = (rng * 1103515245 + 12345) >>> 0;
      return options[rng % options.length]!;
    },
  };
}

// ---------------------------------------------------------------------------

export type PlayOutcome = {
  finished: boolean;
  turns: number;
  days: number;
  revenue: number[];
  collisions: number;
  trainsScheduled: number;
  cardsPlayed: number;
  outcome: GameState['outcome'];
  /** The full ordered log. `state = fold(events)`, so this is the complete record of the game. */
  events: GameEvent[];
  /** Every intent the bot actually submitted, for action-mix analysis. */
  intents: Intent['type'][];
};

/**
 * Drives a game to completion with the given policy. This is the whole reason the phase driver
 * lives inside the engine: it is a plain loop over `advance` and `applyIntent`, with no server.
 */
export function playGame(
  s: GameState,
  policy: BotPolicy,
  pumpFn: (s: GameState) => GameEvent[],
  maxTurns = 50_000,
): PlayOutcome {
  const events: GameEvent[] = [];
  const intents: Intent['type'][] = [];
  let collisions = 0;
  let trainsScheduled = 0;
  let cardsPlayed = 0;
  let turns = 0;

  const tally = (batch: GameEvent[]): void => {
    events.push(...batch);
    for (const e of batch) {
      if (e.type === 'trainScheduled') trainsScheduled++;
      else if (e.type === 'cardPlayed') cardsPlayed++;
      else if (
        e.type === 'revenueChanged' &&
        'reason' in e &&
        String((e as { reason: string }).reason).startsWith('collision')
      ) {
        collisions++;
      }
    }
  };

  for (; turns < maxTurns; turns++) {
    tally(pumpFn(s));
    if (s.status === 'finished') break;

    const actor =
      s.clock.pendingDecision !== null ? s.clock.superintendent : s.clock.currentActor;
    if (actor === null) break;

    const options = legalActions(s, actor);
    if (options.length === 0) break;

    const chosen = policy.choose(s, actor, options);
    intents.push(chosen.type);
    const r = applyIntent(s, actor, chosen);
    if (!r.ok) throw new Error(`${policy.name} bot chose an illegal action: ${r.code}`);
    tally(r.events);
  }

  return {
    finished: s.status === 'finished',
    turns,
    days: s.clock.day,
    revenue: s.players.map((p) => p.revenue),
    collisions,
    trainsScheduled,
    cardsPlayed,
    outcome: s.outcome,
    events,
    intents,
  };
}
