/**
 * Build order step 3 — "Individual actions apply correctly; illegal ones rejected."
 * See architecture/components.md §4.
 */

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

import { applyIntent, check, areaOf } from '../src/engine/apply.ts';
import { HAND_LIMIT, MOVES_PER_LOCAL_OPS } from '../src/engine/content.ts';
import type { Intent } from '../src/engine/intents.ts';
import { legalActions } from '../src/engine/legal.ts';
import { createGame } from '../src/engine/setup.ts';
import type { CrewTray, GameConfig, GameState, GridCoord, TrackCard } from '../src/engine/state.ts';
import { coordKey } from '../src/engine/state.ts';

const config: GameConfig = {
  mode: 'solitaire',
  victory: 'highestAfterDays',
  length: 'standard',
  optionalRules: {
    reducedVisibility: false,
    sisterTrains: false,
    employeeRotation: false,
    emergencyToolbox: false,
  },
};

const game = (seed = 77): GameState =>
  createGame({ id: 'g', seed, config, playerNames: ['Jesse'] });

const at = (row: number, col: number): GridCoord => ({ row, col });

/** Puts a tray on the player's grid so switching intents have something to act on. */
function placeTray(s: GameState, coord: GridCoord, consist: CrewTray['consist'] = []): string {
  const id = s.freeTrays.pop()!;
  s.trays.set(id, {
    id,
    trainNumber: null,
    trainIsExtra: false,
    engineFront: true,
    consist,
    direction: 'east',
    position: { at: 'grid', owner: 0, coord },
    movesUsed: 0,
  });
  return id;
}

function addCard(s: GameState, coord: GridCoord, card: TrackCard): void {
  areaOf(s, 0).grid.set(coordKey(coord), card);
}

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

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

describe('turn and phase gating', () => {
  it('rejects an intent from a player who is not the actor', () => {
    const s = game();
    s.clock.currentActor = 1;
    assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'NOT_YOUR_TURN');
  });

  it('rejects an intent belonging to another phase', () => {
    const s = game();
    s.clock.phase = 'loadUnload';
    assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
  });

  it('rejects everything once the game is finished', () => {
    const s = game();
    s.status = 'finished';
    assert.equal(check(s, 0, { type: 'localOps.choose', option: 'draw' }), 'WRONG_PHASE');
  });
});

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

describe('Local Operations: the three-way exclusive choice (§6)', () => {
  it('accepts a first choice and records it', () => {
    const s = game();
    const r = applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    assert.ok(r.ok);
    assert.equal(s.turn.option, 'draw');
  });

  it('forecloses the other two options for the Stage', () => {
    const s = game();
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    assert.equal(
      check(s, 0, { type: 'localOps.choose', option: 'switch' }),
      'OPTION_ALREADY_CHOSEN',
    );
  });

  it('refuses sub-intents of an option that was not chosen', () => {
    const s = game();
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    assert.equal(check(s, 0, { type: 'switch.end' }), 'OPTION_NOT_CHOSEN');
  });
});

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

describe('Local Operations: drawing (§6.2)', () => {
  it('draws from the Home Office deck into the hand', () => {
    const s = game();
    const before = s.decks.homeOffice.length;
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    const r = applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
    assert.ok(r.ok);
    assert.equal(s.decks.homeOffice.length, before - 1);
    assert.equal(s.decks.hands.get(0)!.length, 4);
  });

  it('takes the face-up card from a Department slot', () => {
    const s = game();
    const target = s.decks.departments[1]!;
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    const r = applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 1 });
    assert.ok(r.ok);
    assert.ok(s.decks.hands.get(0)!.includes(target));
    // §6.2 — an emptied Department slot is refilled from the Home Office deck immediately, so the
    // face-up market never disappears.
    assert.notEqual(s.decks.departments[1], null, 'Department slot was not refilled');
    assert.notEqual(s.decks.departments[1], target, 'refilled with the same card');
  });

  it('allows only one draw per Stage', () => {
    const s = game();
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
    assert.equal(check(s, 0, { type: 'draw.fromHomeOffice' }), 'OPTION_ALREADY_CHOSEN');
  });

  it('will not end the phase over the hand limit', () => {
    // §6.2 — "must reduce his hand to no more than three cards".
    const s = game();
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    applyIntent(s, 0, { type: 'draw.fromHomeOffice' });
    assert.equal(s.decks.hands.get(0)!.length, HAND_LIMIT + 1);
    assert.equal(check(s, 0, { type: 'draw.end' }), 'HAND_LIMIT');
  });

  it('discards face up onto a Department slot, restoring the limit', () => {
    const s = game();
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    applyIntent(s, 0, { type: 'draw.fromDepartment', slot: 0 });
    const spare = s.decks.hands.get(0)![0]!;
    const r = applyIntent(s, 0, { type: 'card.discard', cardId: spare, toSlot: 0 });
    assert.ok(r.ok);
    assert.equal(s.decks.departments[0], spare, 'discards go face up onto a Department');
    assert.equal(check(s, 0, { type: 'draw.end' }), null);
  });

  it('refuses to play a card that is not in hand', () => {
    const s = game();
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    assert.equal(
      check(s, 0, { type: 'card.play', cardId: 'nonsense' }),
      'CARD_NOT_IN_HAND',
    );
  });
});

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

describe('Office upgrades (Gap 3b, Gap 8)', () => {
  function handCardOfTier(s: GameState, tier: 'depot' | 'station') {
    for (const [id, card] of s.cards) {
      if (card.kind.kind === 'office' && card.kind.tier === tier) {
        s.decks.hands.set(0, [id]);
        return id;
      }
    }
    throw new Error('no such office card');
  }

  it('upgrades a Whistle Post to a Depot', () => {
    const s = game();
    const id = handCardOfTier(s, 'depot');
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    const r = applyIntent(s, 0, { type: 'card.play', cardId: id });
    assert.ok(r.ok);
    assert.equal(areaOf(s, 0).tier, 'depot');
  });

  it('refuses to skip a tier', () => {
    const s = game();
    const id = handCardOfTier(s, 'station');
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    assert.equal(check(s, 0, { type: 'card.play', cardId: id }), 'NOT_UPGRADEABLE');
  });

  it('preserves every grid connection through an upgrade', () => {
    // Gap 8 — an upgrade is a PROPERTY change, not a card swap. Swapping would orphan
    // Secondary Track hanging off the Office.
    const s = game();
    addCard(s, at(-1, 0), straight());
    const gridBefore = new Map(areaOf(s, 0).grid);
    const id = handCardOfTier(s, 'depot');
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    applyIntent(s, 0, { type: 'card.play', cardId: id });

    const gridAfter = areaOf(s, 0).grid;
    assert.equal(gridAfter.size, gridBefore.size, 'no card added or lost');
    for (const key of gridBefore.keys()) assert.ok(gridAfter.has(key), `lost ${key}`);
  });

  it('grants the Depot its Porters and slots on upgrade', () => {
    const s = game();
    const id = handCardOfTier(s, 'depot');
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    applyIntent(s, 0, { type: 'card.play', cardId: id });
    const office = areaOf(s, 0).grid.get(coordKey(areaOf(s, 0).officeCoord))!;
    assert.equal(office.facility!.porters, 1);
    // The design gives slots equal to porters, not one more.
    assert.equal(office.facility!.capacity.inbound, 1);
  });
});

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

describe('Local Operations: switching (§6.1, Appendix A)', () => {
  it('moves a tray and spends a Move', () => {
    const s = game();
    addCard(s, at(0, 2), straight());
    const tray = placeTray(s, at(0, 1));
    applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
    const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
    assert.ok(r.ok);
    assert.equal(s.turn.movesRemaining, MOVES_PER_LOCAL_OPS - 1);
  });

  it('couples standing cars automatically and mandatorily', () => {
    // §A.4 — "you MUST pick them up".
    const s = game();
    addCard(s, at(0, 2), straight([{ type: 'boxcar', loaded: false }]));
    const tray = placeTray(s, at(0, 1));
    applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
    const r = applyIntent(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 2), reverse: false });
    assert.ok(r.ok);
    assert.ok(r.events.some((e) => e.type === 'carsCoupled'), 'coupling must be an event');
    assert.equal(s.trays.get(tray)!.consist.length, 1);
  });

  it('refuses a move to an unreachable card', () => {
    const s = game();
    const tray = placeTray(s, at(0, 1));
    applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
    assert.equal(
      check(s, 0, { type: 'switch.move', trayId: tray, to: at(9, 9), reverse: false }),
      'ILLEGAL_MOVE',
    );
  });

  it('runs out of Moves after six', () => {
    const s = game();
    const tray = placeTray(s, at(0, 1));
    applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
    s.turn.movesRemaining = 0;
    assert.equal(
      check(s, 0, { type: 'switch.move', trayId: tray, to: at(0, 0), reverse: false }),
      'NO_MOVES_REMAINING',
    );
  });

  it('drops cars from the seated end, in order', () => {
    // §A.3 — cars must come off in the order they are seated in the Crew Tray.
    const s = game();
    addCard(s, at(0, 2), straight());
    const tray = placeTray(s, at(0, 2), [
      { type: 'boxcar', loaded: false },
      { type: 'hopper', loaded: true },
    ]);
    applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
    const r = applyIntent(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 });
    assert.ok(r.ok);
    const left = areaOf(s, 0).grid.get(coordKey(at(0, 2)))!.standing;
    assert.equal(left.length, 1);
    assert.equal(left[0]!.type, 'hopper', 'the last-seated car comes off first');
    assert.equal(s.trays.get(tray)!.consist.length, 1);
  });

  it('refuses to drop cars at the Office', () => {
    // §A.4 — a passenger platform is no place to switch Rolling Stock.
    const s = game();
    const office = areaOf(s, 0).officeCoord;
    const tray = placeTray(s, office, [{ type: 'boxcar', loaded: false }]);
    applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
    assert.equal(
      check(s, 0, { type: 'switch.dropCars', trayId: tray, count: 1 }),
      'CANNOT_DROP_HERE',
    );
  });
});

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

describe('Freight Agent operations (§6.3)', () => {
  function withFacility(s: GameState): GridCoord {
    const coord = at(-1, 0);
    addCard(s, coord, {
      geometry: { kind: 'facility', facility: 'mineTipple' },
      baseOperationalRail: true,
      standing: [],
      facility: {
        kind: 'freight',
        subtype: 'mineTipple',
        allows: { outbound: true, inbound: false },
        outboundBox: [],
        inboundBox: [],
        capacity: { outbound: 3, inbound: 0 },
        menAtWork: [null, null, null],
        industryTrack: { length: 4, cars: [] },
        laborers: 3,
        porters: 0,
        usedThisStage: { laborers: 0, porters: 0 },
      },
      modifiers: [],
  enhancements: [],
    });
    return coord;
  }

  it('stocks the green Outbound box from the Division Yard', () => {
    const s = game();
    const coord = withFacility(s);
    const before = s.yards.divisionYard.length;
    applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
    const r = applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
    assert.ok(r.ok);
    assert.equal(s.yards.divisionYard.length, before - 1);
    assert.equal(areaOf(s, 0).grid.get(coordKey(coord))!.facility!.outboundBox.length, 1);
  });

  it('allows only one Freight Agent operation per Stage', () => {
    // This is the bottleneck the whole economy rests on (card-reference.md §7).
    const s = game();
    const coord = withFacility(s);
    applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
    applyIntent(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' });
    assert.equal(
      check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
      'OPTION_ALREADY_CHOSEN',
    );
  });

  it('refuses to overfill the Outbound box', () => {
    const s = game();
    const coord = withFacility(s);
    const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
    f.outboundBox = [
      { type: 'hopper', loaded: true },
      { type: 'hopper', loaded: true },
      { type: 'hopper', loaded: true },
    ];
    applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
    assert.equal(
      check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
      'BOX_FULL',
    );
  });

  it('refuses to load a facility that only unloads', () => {
    const s = game();
    const coord = withFacility(s);
    const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
    f.allows = { outbound: false, inbound: true };
    // Give it something to clear, so the Freight Agent option itself remains available.
    f.inboundBox = [{ type: 'hopper', loaded: true }];
    const chose = applyIntent(s, 0, { type: 'localOps.choose', option: 'freightAgent' });
    assert.ok(chose.ok);
    assert.equal(
      check(s, 0, { type: 'freightAgent.stockOutbound', at: coord, carType: 'hopper' }),
      'NO_SUCH_FACILITY',
    );
  });

  it('makes the Freight Agent option unavailable with nothing to operate (§6)', () => {
    // A player with no Facility cannot choose an option that has no possible follow-up.
    const s = game();
    assert.equal(
      check(s, 0, { type: 'localOps.choose', option: 'freightAgent' }),
      'NO_SUCH_FACILITY',
    );
  });

  it('makes the switch option unavailable with no train to switch', () => {
    const s = game();
    assert.equal(check(s, 0, { type: 'localOps.choose', option: 'switch' }), 'NO_SUCH_TRAY');
  });
});

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

describe('Load/Unload: the four-action freight pipeline (§9.3)', () => {
  function facilityWithLoad(s: GameState): GridCoord {
    const coord = at(-1, 0);
    addCard(s, coord, {
      geometry: { kind: 'facility', facility: 'mineTipple' },
      baseOperationalRail: true,
      standing: [],
      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: [{ type: 'hopper', loaded: false }] },
        laborers: 3,
        porters: 0,
        usedThisStage: { laborers: 0, porters: 0 },
      },
      modifiers: [],
  enhancements: [],
    });
    s.clock.phase = 'loadUnload';
    return coord;
  }

  it('advances a load one box per Laborer action', () => {
    const s = game();
    const coord = facilityWithLoad(s);
    const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 });
    assert.ok(r.ok);
    const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
    assert.equal(f.menAtWork[0], null);
    assert.notEqual(f.menAtWork[1], null);
    assert.equal(f.usedThisStage.laborers, 1);
  });

  it('spends each Laborer only once per Stage', () => {
    // §9.1 — Laborers and Porters may be used once each in a Stage.
    const s = game();
    const coord = facilityWithLoad(s);
    const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
    f.usedThisStage.laborers = f.laborers;
    assert.equal(
      check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 0 }),
      'RESOURCE_SPENT',
    );
  });

  it('scores exactly one Revenue when a load comes off WORK', () => {
    const s = game();
    const coord = facilityWithLoad(s);
    const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
    f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];

    assert.equal(s.players[0]!.revenue, 0);
    const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 });
    assert.ok(r.ok);
    assert.equal(s.players[0]!.revenue, 1, 'one point per completed load');
    assert.ok(r.events.some((e) => e.type === 'loadCompleted'));
    assert.equal(f.industryTrack.cars[0]!.loaded, true, 'the spotted car is now loaded');
  });

  it('will not complete a load with no empty car spotted', () => {
    const s = game();
    const coord = facilityWithLoad(s);
    const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
    f.menAtWork = [null, null, { type: 'hopper', dir: 'out' }];
    f.industryTrack.cars = [];
    assert.equal(check(s, 0, { type: 'laborer.advanceLoad', at: coord, box: 2 }), 'BOX_EMPTY');
  });

  it('takes four Laborer actions in total for one point', () => {
    // The asymmetry the whole economy is calibrated against (card-reference.md §2).
    const s = game();
    const coord = facilityWithLoad(s);
    const f = areaOf(s, 0).grid.get(coordKey(coord))!.facility!;
    f.laborers = 99; // isolate the pipeline from the per-Stage cap

    let actions = 0;
    for (let box = 0; box < 3; box++) {
      const r = applyIntent(s, 0, { type: 'laborer.advanceLoad', at: coord, box });
      assert.ok(r.ok, `advance from box ${box}`);
      actions++;
    }
    assert.equal(actions, 3, 'Green->MEN, MEN->AT, AT->WORK');
    assert.equal(s.players[0]!.revenue, 1, 'the third advance came off WORK and scored');
  });
});

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

describe('the Superintendent clearance ruling (§8.1)', () => {
  it('is refused when no decision is pending', () => {
    const s = game();
    assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NO_PENDING_DECISION');
  });

  it('is accepted out of turn order, because it interrupts an automatic phase', () => {
    const s = game();
    s.clock.phase = 'mainline';
    s.clock.currentActor = null; // nobody's turn — yet the Superintendent must still rule
    s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
    const r = applyIntent(s, 0, { type: 'mainline.clearance', allow: false });
    assert.ok(r.ok);
    assert.equal(s.clock.pendingDecision, null);
  });

  it('is refused to a player who is not the Superintendent', () => {
    const s = game();
    s.clock.pendingDecision = { train: 'tray0', occupiedBy: 'tray1' };
    s.clock.superintendent = 1;
    assert.equal(check(s, 0, { type: 'mainline.clearance', allow: true }), 'NOT_SUPERINTENDENT');
  });
});

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

describe('legalActions shares its rules with apply (component 6)', () => {
  it('offers only intents that apply would accept', () => {
    // The whole discipline of legal.ts in one assertion.
    const s = game();
    for (const i of legalActions(s, 0)) {
      assert.equal(check(s, 0, i), null, `offered an illegal intent: ${i.type}`);
    }
  });

  const chooseOptions = (s: GameState) =>
    legalActions(s, 0)
      .filter((i): i is Extract<Intent, { type: 'localOps.choose' }> => i.type === 'localOps.choose')
      .map((i) => i.option)
      .sort();

  it('offers only "draw" on the opening Stage', () => {
    // §6 — the other two options have no possible follow-up yet: a player opens with no Facility
    // and no train. This is why the very first Stages are forced development.
    assert.deepEqual(chooseOptions(game()), ['draw']);
  });

  it('offers all three once the player has a facility and a train', () => {
    const s = game();
    addCard(s, at(0, 2), straight());
    placeTray(s, at(0, 2));
    addCard(s, at(1, 0), {
      geometry: { kind: 'facility', facility: 'mineTipple' },
      baseOperationalRail: true,
      standing: [],
      facility: {
        kind: 'freight',
        subtype: 'mineTipple',
        allows: { outbound: true, inbound: false },
        outboundBox: [],
        inboundBox: [],
        capacity: { outbound: 3, inbound: 0 },
        menAtWork: [null, null, null],
        industryTrack: { length: 4, cars: [] },
        laborers: 3,
        porters: 0,
        usedThisStage: { laborers: 0, porters: 0 },
      },
      modifiers: [],
  enhancements: [],
    });
    assert.deepEqual(chooseOptions(s), ['draw', 'freightAgent', 'switch']);
  });

  it('narrows to one option once a choice is made', () => {
    const s = game();
    applyIntent(s, 0, { type: 'localOps.choose', option: 'draw' });
    const kinds = new Set(legalActions(s, 0).map((i) => i.type));
    assert.ok(!kinds.has('localOps.choose'), 'the choice is spent');
    assert.ok(kinds.has('draw.fromHomeOffice'), 'draw sub-intents are now available');
    assert.ok(!kinds.has('switch.end'), 'switch sub-intents are not');
  });

  it('offers reachable destinations for a placed tray', () => {
    const s = game();
    addCard(s, at(0, 2), straight());
    const tray = placeTray(s, at(0, 1));
    applyIntent(s, 0, { type: 'localOps.choose', option: 'switch' });
    const moves = legalActions(s, 0).filter((i) => i.type === 'switch.move');
    assert.ok(moves.length > 0, 'a tray with open track should have somewhere to go');
    for (const m of moves) assert.equal(check(s, 0, m), null);
    assert.ok(moves.some((m) => m.type === 'switch.move' && m.trayId === tray));
  });

  it('offers nothing illegal after the game ends', () => {
    const s = game();
    s.status = 'finished';
    assert.equal(legalActions(s, 0).length, 0);
  });
});
