/**
 * Build order step 2 — "Move legality provable against Appendix A's worked switching examples."
 * See architecture/components.md §4.
 */

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';

import type {
  GridCoord,
  OfficeArea,
  RollingStock,
  TrackCard,
  TurnoutOrientation,
} from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';
import type { MoveContext, Occupancy, Port } from '../src/engine/track.ts';
import {
  allReachable,
  canDropCarsAt,
  canPlaceAt,
  exitsFrom,
  hasPort,
  neighbour,
  opposite,
  reachableDestinations,
} from '../src/engine/track.ts';

// ---------------------------------------------------------------------------
// Fixture helpers
// ---------------------------------------------------------------------------

const straight = (standing: RollingStock[] = []): TrackCard => ({
  geometry: { kind: 'track', geometry: 'straight' },
  baseOperationalRail: true,
  standing,
  facility: null,
  modifiers: [],
  enhancements: [],
});

/** A straight rotated to run north-south. Needed to meet the Office's junction stubs (Gap 11). */
const nsStraight = (standing: RollingStock[] = []): TrackCard => ({
  geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
  baseOperationalRail: true,
  standing,
  facility: null,
  modifiers: [],
  enhancements: [],
});

const turnout = (o?: TurnoutOrientation): TrackCard => ({
  geometry: o
    ? { kind: 'track', geometry: 'turnout', turnout: o }
    : { kind: 'track', geometry: 'turnout' },
  baseOperationalRail: false, // no wheel icon — a train may pass through but not stop (§A.1)
  standing: [],
  facility: null,
  modifiers: [],
  enhancements: [],
});

const officeCard = (): TrackCard => ({
  geometry: { kind: 'office' },
  baseOperationalRail: true,
  standing: [],
  facility: null,
  modifiers: [],
  enhancements: [],
});

const lockedFacility = (): TrackCard => ({
  geometry: { kind: 'facility', facility: 'mineTipple' },
  baseOperationalRail: true,
  standing: [],
  // A load sitting on MEN|AT|WORK locks the industry track down (§9.3).
  facility: {
    kind: 'freight',
    subtype: 'mineTipple',
    allows: { outbound: true, inbound: false },
    outboundBox: [],
    inboundBox: [],
    capacity: { outbound: 3, inbound: 0 },
    menAtWork: [{ type: 'hopper', dir: 'out' }, null, null],
    industryTrack: { length: 4, cars: [] },
    laborers: 3,
    porters: 0,
    usedThisStage: { laborers: 0, porters: 0 },
  },
  modifiers: [],
  enhancements: [],
});

const car = (): RollingStock => ({ type: 'boxcar', loaded: false });

function areaFrom(cards: Record<string, TrackCard>, officeCoord: GridCoord): OfficeArea {
  const grid = new Map<string, TrackCard>();
  for (const [k, v] of Object.entries(cards)) grid.set(k, v);
  return {
    owner: 0,
    tier: 'whistlePost',
    grid,
    officeCoord,
    runningRow: officeCoord.row,
    limitsWest: { row: officeCoord.row, col: officeCoord.col - 2 },
    limitsEast: { row: officeCoord.row, col: officeCoord.col + 2 },
    adOccupancy: [],
    heldAtLimits: [],
    dispatchUsedToday: [],
    trackSupply: new Map(),
  };
}

const emptyOccupancy: Occupancy = { trayAt: () => null, freeAdTracks: () => 1 };

function ctxFor(area: OfficeArea, over: Partial<MoveContext> = {}): MoveContext {
  return { area, occupancy: emptyOccupancy, consistSize: 0, self: 'tray0', ...over };
}

const at = (r: number, c: number): GridCoord => ({ row: r, col: c });
const has = (dests: { coord: GridCoord }[], r: number, c: number): boolean =>
  dests.some((d) => d.coord.row === r && d.coord.col === c);

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

describe('ports and geometry', () => {
  it('pairs opposite ports and grid neighbours consistently', () => {
    for (const p of ['n', 's', 'e', 'w'] as Port[]) {
      assert.equal(opposite(opposite(p)), p);
      const there = neighbour(at(0, 0), p);
      const back = neighbour(there, opposite(p));
      assert.deepEqual(back, at(0, 0));
    }
  });

  it('joins a turnout stem to both legs but never the legs to each other', () => {
    // §A.1 — the whole point. Entering from the stem reaches either leg; entering from a leg
    // reaches only the stem. This is an ABSENT edge, not a one-way edge.
    const t = turnout({ stem: 'e', through: 'w', diverge: 's' });
    assert.deepEqual(exitsFrom(t, 'e').sort(), ['s', 'w']);
    assert.deepEqual(exitsFrom(t, 'w'), ['e']);
    assert.deepEqual(exitsFrom(t, 's'), ['e']);
  });

  it('lets a train traverse a turnout in both directions', () => {
    const t = turnout({ stem: 'e', through: 'w', diverge: 's' });
    assert.ok(exitsFrom(t, 'e').includes('s'), 'stem to diverging leg');
    assert.ok(exitsFrom(t, 's').includes('e'), 'diverging leg back to stem');
  });

  it('gives the Office card a junction stub BELOW only', () => {
    // Q7 — the card art draws the through-track along the top edge with everything diverging
    // downward, so a district grows beneath the Running Track. Supersedes Gap 8.
    const o = officeCard();
    for (const p of ['s', 'e', 'w'] as Port[]) assert.ok(hasPort(o, p), `office port ${p}`);
    assert.ok(!hasPort(o, 'n'), 'nothing may attach above the Running Track');
    assert.ok(exitsFrom(o, 's').includes('e'));
    assert.ok(exitsFrom(o, 's').includes('w'));
  });

  it('joins a curve from one end of the through track downward', () => {
    // Curves come in left and right hands as distinct cards; the leg always goes DOWN (Q7), and
    // handedness decides which end of the through track it leaves from.
    const r: TrackCard = {
      geometry: { kind: 'track', geometry: 'curved', hand: 'left' },
      baseOperationalRail: true,
      standing: [],
      facility: null,
      modifiers: [],
  enhancements: [],
    };
    assert.deepEqual(exitsFrom(r, 's'), ['w']);
  });
});

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

describe('Move legality', () => {
  //  row  0: [D] [E office      ] [F]   <- Running Track
  //  row -1:     [B n-s straight]        <- Secondary Track hangs BELOW (Q7)
  const basicArea = (): OfficeArea =>
    areaFrom(
      {
        [coordKey(at(0, -1))]: straight(),
        [coordKey(at(0, 0))]: officeCard(),
        [coordKey(at(0, 1))]: straight(),
        [coordKey(at(-1, 0))]: nsStraight(),
      },
      at(0, 0),
    );

  it('travels any distance in a straight line', () => {
    // §2.4 — "regardless of distance".
    const area = basicArea();
    const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
    assert.ok(has(dests, 0, 0), 'reaches the office');
    assert.ok(has(dests, 0, -1), 'reaches past it');
  });

  it('branches onto Secondary Track through the Office stub', () => {
    const area = basicArea();
    const dests = reachableDestinations(ctxFor(area), at(0, 1), 'w');
    assert.ok(has(dests, -1, 0), 'reaches the card below the office');
  });

  it('never reverses within a single Move', () => {
    // Leaving east from the west end cannot double back to the west end.
    const area = basicArea();
    const dests = reachableDestinations(ctxFor(area), at(0, -1), 'e');
    assert.ok(!has(dests, 0, -1), 'a Move may not return to its own start');
  });

  it('separates forward and reverse into different Moves', () => {
    const area = basicArea();
    const both = allReachable(ctxFor(area), at(0, 0), 'e');
    assert.ok(has(both.forward, 0, 1));
    assert.ok(has(both.reverse, 0, -1));
    assert.ok(!has(both.forward, 0, -1), 'reverse destinations must cost their own Move');
  });

  it('will not stop on a turnout', () => {
    //  row  0: [start] [turnout       ]
    //  row -1:         [C n-s straight]
    const area = areaFrom(
      {
        [coordKey(at(0, 0))]: straight(),
        [coordKey(at(0, 1))]: turnout({ stem: 'w', through: 'e', diverge: 's' }),
        [coordKey(at(-1, 1))]: nsStraight(),
      },
      at(0, 0),
    );
    const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
    assert.ok(!has(dests, 0, 1), 'a turnout carries no wheel icon (§A.1)');
    assert.ok(has(dests, -1, 1), 'but a train may pass through it');
  });

  it('will not stop on a facility whose track is locked by a load', () => {
    // §9.3 — while any load sits on MEN|AT|WORK the industry track is not Operational Rail.
    const area = areaFrom(
      {
        [coordKey(at(0, 0))]: straight(),
        [coordKey(at(0, 1))]: lockedFacility(),
      },
      at(0, 0),
    );
    const dests = reachableDestinations(ctxFor(area), at(0, 0), 'e');
    assert.ok(!has(dests, 0, 1), 'locked industry track must not accept a train');
  });
});

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

describe('coupling', () => {
  const withCars = (n: number): OfficeArea =>
    areaFrom(
      {
        [coordKey(at(0, 0))]: straight(),
        [coordKey(at(0, 1))]: straight(Array.from({ length: n }, car)),
        [coordKey(at(0, 2))]: straight(),
      },
      at(0, 0),
    );

  it('couples cars met along the way, mandatorily', () => {
    // §A.4 — "you MUST pick them up. You may not go around them."
    const dests = reachableDestinations(ctxFor(withCars(2)), at(0, 0), 'e');
    const beyond = dests.find((d) => d.coord.col === 2);
    assert.ok(beyond, 'should be able to pass the occupied card');
    assert.equal(beyond.couples.length, 2, 'cars must be collected, not stepped over');
  });

  it('forbids a Move that would overfill the tray', () => {
    // §A.4 — max four Rolling Stock, cabooses included. Overfilling makes the move illegal
    // rather than picking up fewer cars.
    const ctx = ctxFor(withCars(3), { consistSize: 3 });
    const dests = reachableDestinations(ctx, at(0, 0), 'e');
    assert.equal(dests.length, 0, '3 in tray + 3 standing exceeds 4');
  });

  it('permits a Move that exactly fills the tray', () => {
    const ctx = ctxFor(withCars(2), { consistSize: 2 });
    const dests = reachableDestinations(ctx, at(0, 0), 'e');
    assert.ok(dests.length > 0, '2 + 2 = 4 is legal');
  });
});

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

describe('occupancy', () => {
  const twoTrainArea = (): OfficeArea =>
    areaFrom(
      {
        [coordKey(at(0, 0))]: straight(),
        [coordKey(at(0, 1))]: straight(),
        [coordKey(at(0, 2))]: straight(),
      },
      at(0, 5), // office elsewhere, so col 1 is ordinary track
    );

  it('blocks a card another train occupies', () => {
    // §A.4 — two trains may not share a card or move through each other.
    const occupancy: Occupancy = {
      trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
      freeAdTracks: () => 1,
    };
    const dests = reachableDestinations(ctxFor(twoTrainArea(), { occupancy }), at(0, 0), 'e');
    assert.ok(!has(dests, 0, 1), 'occupied card');
    assert.ok(!has(dests, 0, 2), 'and no passing through it');
  });

  it('allows passing through the Office while A/D tracks remain free', () => {
    const area = areaFrom(
      {
        [coordKey(at(0, 0))]: straight(),
        [coordKey(at(0, 1))]: officeCard(),
        [coordKey(at(0, 2))]: straight(),
      },
      at(0, 1),
    );
    const occupancy: Occupancy = {
      trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
      freeAdTracks: () => 1,
    };
    const dests = reachableDestinations(ctxFor(area, { occupancy }), at(0, 0), 'e');
    assert.ok(has(dests, 0, 2), 'the Office is the exception to the blocking rule (§A.4)');
  });

  it('blocks the Office once every A/D track is taken', () => {
    const area = areaFrom(
      {
        [coordKey(at(0, 0))]: straight(),
        [coordKey(at(0, 1))]: officeCard(),
        [coordKey(at(0, 2))]: straight(),
      },
      at(0, 1),
    );
    const occupancy: Occupancy = {
      trayAt: (c) => (c.row === 0 && c.col === 1 ? 'other' : null),
      freeAdTracks: () => 0,
    };
    const dests = reachableDestinations(ctxFor(area, { occupancy }), at(0, 0), 'e');
    assert.ok(!has(dests, 0, 2));
  });
});

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

describe('placement and drop-off', () => {
  it('lets the first Facility attach to the Office stub', () => {
    // Gap 8 — the case that was impossible before Office cards carried junction stubs.
    const area = areaFrom(
      {
        [coordKey(at(0, -1))]: straight(),
        [coordKey(at(0, 0))]: officeCard(),
        [coordKey(at(0, 1))]: straight(),
      },
      at(0, 0),
    );
    assert.ok(canPlaceAt(area, at(-1, 0), nsStraight()), 'below the Office');
    assert.ok(!canPlaceAt(area, at(1, 0), nsStraight()), 'nothing attaches above the Running Track');
  });

  it('refuses a card whose ports do not meet the neighbour it touches', () => {
    // Gap 11 in miniature: an east-west straight placed north of the Office has no south port,
    // so it cannot attach to the stub. Orientation is not cosmetic.
    const area = areaFrom(
      {
        [coordKey(at(0, -1))]: straight(),
        [coordKey(at(0, 0))]: officeCard(),
        [coordKey(at(0, 1))]: straight(),
      },
      at(0, 0),
    );
    assert.ok(!canPlaceAt(area, at(-1, 0), straight()), 'e-w straight cannot meet a n-s stub');
  });

  it('refuses a card that connects to nothing', () => {
    const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
    assert.ok(!canPlaceAt(area, at(3, 3), straight()), 'orphaned track is never legal');
  });

  it('refuses to place on an occupied cell', () => {
    const area = areaFrom({ [coordKey(at(0, 0))]: officeCard() }, at(0, 0));
    assert.ok(!canPlaceAt(area, at(0, 0), straight()));
  });

  it('forbids dropping cars at the Office', () => {
    // §A.4 — a passenger platform is no place to switch Rolling Stock.
    const area = areaFrom(
      {
        [coordKey(at(0, 0))]: officeCard(),
        [coordKey(at(0, 1))]: straight(),
      },
      at(0, 0),
    );
    assert.ok(!canDropCarsAt(area, at(0, 0)), 'Office track');
    assert.ok(canDropCarsAt(area, at(0, 1)), 'ordinary Operational Rail');
  });

  it('forbids dropping cars on a locked industry track', () => {
    const area = areaFrom(
      {
        [coordKey(at(0, 0))]: officeCard(),
        [coordKey(at(0, 1))]: lockedFacility(),
      },
      at(0, 0),
    );
    assert.ok(!canDropCarsAt(area, at(0, 1)));
  });
});
