/**
 * Mainline modifiers and Maneuver cards (implications.md §7).
 *
 * The 14 cards left after the Enhancements. These are NOT multiplayer-only: Brakeman, Airbrakes,
 * Helpers and Realignment all change your own crossing times, and Red Flags prevents a rear-ender,
 * which happens in solitaire too.
 *
 * Poling is deliberately absent — the recovered sheet records its effect as "TBD in the source", so
 * there is nothing to implement and nothing to test.
 */

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

import { advance } from '../src/engine/advance.ts';
import { applyIntent, areaOf, check } from '../src/engine/apply.ts';
import {
  MAINLINE_MODIFIER_CARDS,
  MANEUVER_CARDS,
  REALIGNMENTS,
  crossingStages,
  mainlineModifierRule,
} from '../src/engine/content.ts';
import { createGame } from '../src/engine/setup.ts';
import type { 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 = 5): GameState => createGame({ id: 'g', seed, config, playerNames: ['p'] });
const at = (row: number, col: number): GridCoord => ({ row, col });

/** Puts a card of `kind`/`key` in hand and returns its id. */
function hand(s: GameState, kind: string, key: string): string {
  for (const [id, card] of s.cards) {
    const k = card.kind as { kind: string; key?: string };
    if (k.kind === kind && k.key === key) {
      s.decks.hands.set(0, [id]);
      return id;
    }
  }
  throw new Error(`no ${kind} card: ${key}`);
}

/** A Mainline node pinned to a known terrain, so deck composition cannot decide an outcome. */
function pinned(s: GameState, index: number, card: string) {
  const node = s.division.nodes[index];
  if (node?.kind !== 'mainline') throw new Error(`node ${index} is not a Mainline card`);
  node.card = card as never;
  node.transits = [];
  return node;
}

/** Lets the player actually play a card this turn. */
function drawTurn(s: GameState): void {
  s.clock.phase = 'localOps';
  s.clock.currentActor = 0;
  s.turn.option = 'draw';
  s.turn.drawnThisTurn = true;
}

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

describe('grade modifiers change crossing time', () => {
  it('a Heavy Grade takes two Stages bare', () => {
    assert.equal(crossingStages('heavyGrade', 'fast', false), 2);
  });

  it('Brakeman speeds the descent but not the climb', () => {
    // Q11 — the card prints "(Up)" and "Player sets orientation", so the last argument is which
    // way is UPHILL. With up = east, a westbound train is descending.
    assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'east'), 1);
    assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'east'), 2);
  });

  it('follows the orientation the player chose, not a fixed compass direction', () => {
    // The same train on the same card, with the card turned around: Brakeman helps a westbound
    // train on an east-climbing grade, and an eastbound one when the grade climbs west.
    assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'east', 'west'), 1);
    assert.equal(crossingStages('heavyGrade', 'fast', false, ['brakeman'], 'west', 'west'), 2);
    assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'west'), 1);
    assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'west'), 2);
  });

  it('Helpers speed the climb but not the descent', () => {
    assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'east', 'east'), 1);
    assert.equal(crossingStages('heavyGrade', 'fast', false, ['helpers'], 'west', 'east'), 2);
  });

  it('Airbrakes stack with Brakeman on a slow train', () => {
    // A slow train pays 3 on a grade; Brakeman and Airbrakes take one Stage each.
    assert.equal(crossingStages('heavyGrade', 'slow', false, [], 'west', 'east'), 3);
    assert.equal(crossingStages('heavyGrade', 'slow', false, ['brakeman'], 'west', 'east'), 2);
    assert.equal(
      crossingStages('heavyGrade', 'slow', false, ['brakeman', 'airbrakes'], 'west', 'east'),
      1,
    );
  });

  it('never lets a train cross in no time', () => {
    assert.equal(
      crossingStages('heavyGrade', 'fast', false, ['brakeman', 'airbrakes'], 'west', 'east'),
      1,
    );
  });

  it('leaves non-grade cards alone', () => {
    // Brakeman on Plains would be an illegal placement anyway; the maths must not move regardless.
    assert.equal(crossingStages('plains', 'fast', false, ['brakeman'], 'west', 'east'), 1);
    assert.equal(crossingStages('curves', 'fast', false, ['helpers'], 'east', 'east'), 2);
  });
});

describe('placing a Mainline modifier', () => {
  it('accepts Brakeman on a grade', () => {
    const s = game();
    pinned(s, 1, 'heavyGrade');
    drawTurn(s);
    const cardId = hand(s, 'mainlineModifier', 'brakeman');
    assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
  });

  it('rejects Brakeman on anything but a grade', () => {
    const s = game();
    pinned(s, 1, 'plains');
    drawTurn(s);
    const cardId = hand(s, 'mainlineModifier', 'brakeman');
    assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_A_GRADE');
  });

  it('requires Brakeman before Airbrakes', () => {
    const s = game();
    const node = pinned(s, 1, 'heavyGrade');
    drawTurn(s);
    const cardId = hand(s, 'mainlineModifier', 'airbrakes');
    assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NOT_CONNECTED');

    node.modifiers = ['brakeman'];
    assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), null);
  });

  it('will not modify a card with a train on it', () => {
    const s = game();
    const node = pinned(s, 1, 'heavyGrade');
    node.transits.push({ tray: 'someone', stagesRemaining: 1, direction: 'east' });
    drawTurn(s);
    const cardId = hand(s, 'mainlineModifier', 'brakeman');
    assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'TRAIN_ON_CARD');
  });

  it('rejects a Mainline node that is not a Mainline card', () => {
    const s = game();
    drawTurn(s);
    const cardId = hand(s, 'mainlineModifier', 'brakeman');
    // Node 0 is the western Division Point.
    assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 0 }), 'NO_PLACEMENT');
  });

  it('lays the modifier and spends the card', () => {
    const s = game();
    const node = pinned(s, 1, 'heavyGrade');
    drawTurn(s);
    const cardId = hand(s, 'mainlineModifier', 'brakeman');
    const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });

    assert.ok(r.ok, 'placement should be accepted');
    assert.deepEqual(node.modifiers, ['brakeman']);
    assert.ok(!(s.decks.hands.get(0) ?? []).includes(cardId), 'card should leave the hand');
    assert.ok(s.decks.salvageYard.includes(cardId), 'card should reach the Salvage Yard');
  });
});

describe('Realignment converts one Mainline type to another', () => {
  it('converts according to the table', () => {
    const s = game();
    const node = pinned(s, 1, 'curves');
    drawTurn(s);
    const cardId = hand(s, 'mainlineModifier', 'realignment');
    const r = applyIntent(s, 0, { type: 'mainline.modify', cardId, node: 1 });

    assert.ok(r.ok);
    assert.equal(node.card, 'plains', 'Curves realigns to Plains');
    // The point of the card: Curves is a 30 (two Stages), Plains a 60 (one).
    assert.equal(crossingStages(node.card, 'fast', false), 1);
  });

  it('refuses a card with no conversion listed', () => {
    const s = game();
    // Tunnel is not a `from` in REALIGNMENTS.
    assert.ok(!REALIGNMENTS.some((r) => r.from === 'tunnel'));
    pinned(s, 1, 'tunnel');
    drawTurn(s);
    const cardId = hand(s, 'mainlineModifier', 'realignment');
    assert.equal(check(s, 0, { type: 'mainline.modify', cardId, node: 1 }), 'NO_PLACEMENT');
  });

  it('applies to any Mainline card, not only grades', () => {
    assert.equal(mainlineModifierRule('realignment')?.gradeOnly, false);
  });
});

describe('Red Flags protect a stopped train', () => {
  /** A slow train `behind` closing on a stopped train `ahead`, both eastbound on node 1. */
  function rearEnder(s: GameState) {
    const node = pinned(s, 1, 'plains');
    node.transits.push({ tray: 'ahead', stagesRemaining: 2, direction: 'east' });
    s.trays.set('ahead', {
      id: 'ahead', trainNumber: 4, trainIsExtra: false, engineFront: true,
      consist: [], direction: 'east', position: { at: 'mainline', index: 1 }, movesUsed: 0,
    });
    s.trays.set('behind', {
      id: 'behind', trainNumber: 2, trainIsExtra: false, engineFront: true,
      consist: [], direction: 'east', position: { at: 'divisionPoint', side: 'west' }, movesUsed: 0,
    });
    const dp = s.division.nodes[0];
    if (dp?.kind === 'divisionPoint') dp.holding.push('behind');
    s.clock.phase = 'mainline';
    s.movedThisPhase = new Set();
    return node;
  }

  it('holds the approaching train instead of letting it close', () => {
    const s = game();
    const node = rearEnder(s);
    node.redFlagged = ['ahead'];

    advance(s);
    assert.deepEqual(
      s.trays.get('behind')!.position,
      { at: 'divisionPoint', side: 'west' },
      'the flagged train must not be approached',
    );
  });

  it('comes in when the protected train rolls', () => {
    const s = game();
    const node = rearEnder(s);
    node.redFlagged = ['ahead'];
    // Bring the protected train to the end of its crossing so it leaves the card.
    node.transits[0]!.stagesRemaining = 1;

    for (let i = 0; i < 12 && (node.redFlagged?.length ?? 0) > 0; i++) advance(s);
    assert.deepEqual(node.redFlagged, [], 'flags come in once the train moves off');
  });

  it('only protects a train out on the Mainline', () => {
    const s = game();
    rearEnder(s);
    s.clock.phase = 'localOps';
    s.clock.currentActor = 0;
    const cardId = hand(s, 'maneuver', 'redFlags');

    assert.equal(
      check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'behind' }),
      'NO_PLACEMENT',
      'a train sitting at a Division Point cannot be rear-ended',
    );
    assert.equal(check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }), null);
  });

  it('will not double-flag the same train', () => {
    const s = game();
    const node = rearEnder(s);
    s.clock.phase = 'localOps';
    s.clock.currentActor = 0;
    const cardId = hand(s, 'maneuver', 'redFlags');
    node.redFlagged = ['ahead'];

    assert.equal(
      check(s, 0, { type: 'maneuver.redFlags', cardId, trayId: 'ahead' }),
      'OPTION_ALREADY_CHOSEN',
    );
  });
});

describe('Flying Switch rolls a cut into an industry', () => {
  /** A crew on a straight with a facility next door, mid-switch. */
  function setup(s: GameState) {
    const area = areaOf(s, 0);
    // A flying switch rolls the cut ALONG THE TRACK, so the spur and the industry must be joined
    // north-south. Built east-west, they are two unconnected cards that merely look adjacent.
    const spur: TrackCard = {
      geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
      baseOperationalRail: true,
      standing: [],
      facility: null,
      modifiers: [],
      enhancements: [],
    };
    area.grid.set(coordKey(at(-1, 0)), spur);
    const industry: TrackCard = {
      geometry: { kind: 'track', geometry: 'straight', axis: 'ns' },
      baseOperationalRail: true,
      standing: [],
      facility: {
        kind: 'freight', subtype: 'mineTipple',
        allows: { outbound: true, inbound: false },
        outboundBox: [], inboundBox: [],
        capacity: { outbound: 1, inbound: 0 },
        menAtWork: [null, null, null],
        industryTrack: { length: 2, cars: [] },
        laborers: 1, porters: 0,
        usedThisStage: { laborers: 0, porters: 0 },
      },
      modifiers: [],
      enhancements: [],
    };
    area.grid.set(coordKey(at(-2, 0)), industry);

    s.trays.set('crew', {
      id: 'crew', trainNumber: 1, trainIsExtra: false, engineFront: true,
      consist: [{ type: 'hopper', loaded: false }, { type: 'boxcar', loaded: false }],
      // Facing SOUTH, down the spur. `direction` only carries east/west, so a crew on north-south
      // track needs its actual port — without it the engine looks for an east exit the card has not
      // got, and the crew has no legal moves at all.
      direction: 'east', facing: 's',
      position: { at: 'grid', owner: 0, coord: at(-1, 0) }, movesUsed: 0,
    });
    s.clock.phase = 'localOps';
    s.clock.currentActor = 0;
    s.turn.option = 'switch';
    s.turn.movesRemaining = 6;
    return industry;
  }

  it('drops the cut onto the industry track without moving the engine', () => {
    const s = game();
    const industry = setup(s);
    const cardId = hand(s, 'maneuver', 'flyingSwitch');

    const r = applyIntent(s, 0, {
      type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-2, 0),
    });
    assert.ok(r.ok, 'the flying switch should be accepted');

    assert.equal(industry.facility!.industryTrack.cars.length, 1, 'car reaches the industry track');
    assert.equal(industry.facility!.industryTrack.cars[0]!.type, 'boxcar', '§A.3 — the back car');
    assert.equal(s.trays.get('crew')!.consist.length, 1, 'the cut leaves the consist');
    assert.deepEqual(
      s.trays.get('crew')!.position,
      { at: 'grid', owner: 0, coord: at(-1, 0) },
      'the engine never enters the industry',
    );
    assert.equal(s.turn.movesRemaining, 5, 'the manoeuvre costs a Move');
  });

  it('only reaches an adjacent card', () => {
    const s = game();
    setup(s);
    const cardId = hand(s, 'maneuver', 'flyingSwitch');
    assert.equal(
      check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-4, 0) }),
      'NOT_CONNECTED',
    );
  });

  it('rolls into an industry, not onto plain track', () => {
    const s = game();
    setup(s);
    const cardId = hand(s, 'maneuver', 'flyingSwitch');
    // (0,0) is the Office — track-connected to the crew, but not a freight industry.
    assert.equal(
      check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(0, 0) }),
      'CANNOT_DROP_HERE',
    );
  });

  it('will not roll onto a card with no track connection', () => {
    const s = game();
    setup(s);
    const area = areaOf(s, 0);
    // An industry sitting beside the crew, but joined east-west while the spur runs north-south.
    const stranded = { ...area.grid.get(coordKey(at(-2, 0)))! };
    stranded.geometry = { kind: 'track', geometry: 'straight', axis: 'ew' };
    area.grid.set(coordKey(at(-1, 1)), stranded);
    const cardId = hand(s, 'maneuver', 'flyingSwitch');
    assert.equal(
      check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 1, to: at(-1, 1) }),
      'NOT_CONNECTED',
    );
  });

  it('cannot cut more cars than it is hauling', () => {
    const s = game();
    setup(s);
    const cardId = hand(s, 'maneuver', 'flyingSwitch');
    assert.equal(
      check(s, 0, { type: 'maneuver.flyingSwitch', cardId, trayId: 'crew', count: 5, to: at(-2, 0) }),
      'CONSIST_EMPTY',
    );
  });
});

describe('card coverage', () => {
  it('every Mainline modifier is either implemented or a known duplicate', () => {
    for (const card of MAINLINE_MODIFIER_CARDS) {
      const known =
        mainlineModifierRule(card.key) !== null || card.key === 'facingPointLocksMainline';
      assert.ok(known, `${card.key} has no rule and is not the Facing Point Locks duplicate`);
    }
  });

  it('Poling remains unimplemented, deliberately', () => {
    // Guard against someone "fixing" this by inventing an effect. The sheet says TBD; until the
    // source says otherwise, a silent no-op would be worse than a rejection.
    const poling = MANEUVER_CARDS.find((c) => c.key === 'poling');
    assert.ok(poling, 'Poling should still be in the deck');
    assert.match(poling!.effect, /TBD/i);
  });
});

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

describe('the Limits sign moves with the Running Track (§2.1, Gap 4a)', () => {
  it('refuses to build beyond the Limits sign', () => {
    // §2.1 — the Running Track runs BETWEEN the Limits, so the sign is the only growth point.
    // Offering the square PAST it produced, from one straight laid at (0,2):
    //   limits · Whistle Post · limits · straight · limits
    // — track on the far side of the sign, and a second sign planted beyond that.
    const s = game();
    const area = areaOf(s, 0);
    s.turn.option = 'draw';

    const beyondEast = { row: area.runningRow, col: area.limitsEast.col + 1 };
    const beyondWest = { row: area.runningRow, col: area.limitsWest.col - 1 };
    for (const placement of [beyondEast, beyondWest]) {
      assert.notEqual(
        check(s, 0, { type: 'track.lay', geometry: 'straight', hand: 'none', placement, variant: 0 }),
        null,
        `(${placement.row},${placement.col}) is outside the Limits and must not be placeable`,
      );
    }

    // The sign itself IS legal — that is how the Running Track grows.
    assert.equal(
      check(s, 0, {
        type: 'track.lay', geometry: 'straight', hand: 'none',
        placement: { row: area.runningRow, col: area.limitsEast.col }, variant: 0,
      }),
      null,
      'laying on the Limits sign must be how the track extends',
    );

    // The district hangs below the Running Track and is not bounded by the Limits at all.
    assert.equal(
      check(s, 0, {
        type: 'track.lay', geometry: 'straight', hand: 'none',
        placement: { row: area.runningRow - 1, col: 0 }, variant: 1,
      }),
      null,
      'Secondary Track below the Office must stay legal',
    );
  });

  it('keeps the Limits at the ends, never stranded mid-track', () => {
    // Found by watching a replay: a district grew to
    //   (0,-5) (0,-4) (0,-3) (0,-2)=Power Plant [LIMITS] (0,0)=Office [LIMITS] (0,2) …
    // with everything beyond (0,-2) built OUTSIDE a sign that never moved. Not cosmetic — §8.1 and
    // §10 both reason about "the track between the train and the Limits", and running past a
    // player's Limits is what makes a collision his fault.
    const s = game();
    const area = areaOf(s, 0);
    s.turn.option = 'draw';

    for (let n = 0; n < 4; n++) {
      s.turn.laidThisTurn = false;
      const target = { row: area.runningRow, col: area.limitsWest.col };
      const r = applyIntent(s, 0, {
        type: 'track.lay', geometry: 'straight', hand: 'none', placement: target, variant: 0,
      });
      assert.ok(r.ok, `extending onto the Limits should be legal (attempt ${n + 1})`);
    }

    const onRunning = [...area.grid.entries()]
      .map(([k, c]) => ({ col: Number(k.split(',')[1]), kind: c.geometry.kind }))
      .filter((x) => !Number.isNaN(x.col));
    const limits = onRunning.filter((x) => x.kind === 'limits').map((x) => x.col).sort((a, b) => a - b);
    const cols = onRunning.map((x) => x.col);

    assert.equal(limits.length, 2, 'there must be exactly two Limits signs');
    assert.equal(limits[0], Math.min(...cols), 'the west sign must be the westernmost card');
    assert.equal(limits[1], Math.max(...cols), 'the east sign must be the easternmost card');
  });
});

describe("a train is made up to its card's consist (§8.2)", () => {
  it('takes a caboose when the card calls for one, and refuses a fourth freight car', () => {
    // Train 9 "Heavy Freight" is freight 3 + caboose 1. It was being made up with FOUR hoppers and
    // no caboose, because placeCar checked only MAX_CONSIST and yard availability.
    const s = game();
    s.clock.phase = 'newTrain';
    s.trays.set('t', {
      id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
      consist: [], direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
    });

    for (let n = 0; n < 3; n++) {
      const r = applyIntent(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true });
      assert.ok(r.ok, `hopper ${n + 1} of 3 should be accepted`);
    }
    assert.equal(
      check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }),
      'NO_SUITABLE_CAR',
      'a fourth freight car exceeds the card',
    );
    assert.equal(
      check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'coach', loaded: true }),
      'NO_SUITABLE_CAR',
      'this card carries no coaches',
    );
    assert.equal(
      check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'caboose', loaded: true }),
      null,
      'the card calls for a caboose',
    );
  });

  it('couples nothing behind the caboose', () => {
    // §A.3 — the caboose rides last.
    const s = game();
    s.clock.phase = 'newTrain';
    s.trays.set('t', {
      id: 't', trainNumber: 9, trainIsExtra: false, engineFront: true,
      consist: [{ type: 'caboose', loaded: true }],
      direction: 'west', position: { at: 'divisionPoint', side: 'east' }, movesUsed: 0,
    });
    assert.equal(
      check(s, 0, { type: 'newTrain.placeCar', trayId: 't', carType: 'hopper', loaded: true }),
      'NO_SUITABLE_CAR',
    );
  });
});
