/**
 * Build order step 1 — "Card data loads; a game state can be constructed and seeded."
 * See architecture/components.md §4.
 */

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

import type { CarType } from '../src/engine/content.ts';
import { MODIFIER_PROFILES, SOLITAIRE_DECK_SIZE, TRACK_PER_PLAYER } from '../src/engine/content.ts';
import {
  DECK_SIZE,
  EXTRA_TRAINS,
  FREIGHT_PROFILES,
  LENGTH_PROFILES,
  MAX_CONSIST,
  OFFICE_PROFILES,
  ROLLING_STOCK_SUPPLY,
  TIMETABLED_TRAINS,
  TOTAL_ROLLING_STOCK,
  crewTrayCount,
  deckComposition,
  isFreightHouse,
  lengthProfile,
  mainlineCardCount,
  nextOfficeTier,
  officeProfile,
} from '../src/engine/content.ts';
import { createRng } from '../src/engine/rng.ts';
import { buildDeck, buildRollingStock, createGame } from '../src/engine/setup.ts';
import type { GameConfig } from '../src/engine/state.ts';
import { subdivisions } from '../src/engine/state.ts';

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

const newSolitaireGame = (seed = 1234) =>
  createGame({ id: 'g1', seed, config: solitaireConfig, playerNames: ['Jesse'] });

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

describe('card catalogue (component 1)', () => {
  it('composes the 140-card deck from the design', () => {
    // Transcribed from docs/Deck cards2.xlsx, whose own total is 115 — plus 18 extra industry cards
    // (Gap 12, industries 9 → 27) and 7 extra office cards (Q12, offices 7 → 14). Both are
    // deliberate departures from the sheet and both are flagged provisional in content.ts.
    assert.equal(DECK_SIZE, 140);
    assert.equal(buildDeck().length, DECK_SIZE);
  });

  it('matches the design deck composition exactly', () => {
    const byCategory = Object.fromEntries(deckComposition().map((c) => [c.category, c.count]));
    assert.deepEqual(byCategory, {
      // 14, not the sheet's 7 — Q12 office density; see OFFICE_PROFILES.
      office: 14,
      // 27, not the sheet's 9 — Gap 12 industry density; see INDUSTRY_PROFILES.
      industry: 27,
      modifier: 23,
      train: 22,
      spaceUse: 12,
      enhancement: 18,
      mainlineModifier: 7,
      maneuver: 7,
      action: 10,
    });
  });

  it('removes opponent-directed cards from a solitaire deck', () => {
    // Q6 — Space-use and Action cards can only be played AT another player, so in a one-player
    // game they would be 22 of 140 draws (16%) that do nothing.
    assert.equal(SOLITAIRE_DECK_SIZE, 118);
    const solo = buildDeck('solitaire');
    assert.equal(solo.length, 118);
    assert.ok(!solo.some((c) => c.kind.kind === 'spaceUse' || c.kind.kind === 'action'));
    // A competitive deck keeps them.
    assert.equal(buildDeck('competitive').length, 140);
  });

  it('keeps track OUT of the deck, as a per-player supply', () => {
    // The single biggest structural change: 26 pieces per player, 104 for four.
    assert.equal(TRACK_PER_PLAYER, 26);
    assert.ok(!buildDeck().some((c) => c.kind.kind === 'track'), 'track leaked into the deck');
  });

  it('has 12 timetabled trains, odd westbound and even eastbound', () => {
    assert.equal(TIMETABLED_TRAINS.length, 12);
    for (const t of TIMETABLED_TRAINS) {
      assert.equal(t.direction, t.number % 2 === 1 ? 'west' : 'east', `train ${t.number}`);
    }
  });

  it('never lets a consist exceed four slots, caboose included', () => {
    // §A.4 + Gap 4c — the most likely off-by-one in the whole model.
    for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
      const slots = t.consist.freight + t.consist.coach + t.consist.caboose;
      assert.ok(slots <= MAX_CONSIST, `${t.name} ${t.number} uses ${slots} slots`);
    }
  });

  it('numbers Extras X13-X22, all junior to every timetabled train', () => {
    // This is why Gap 5's Extra-vs-Timetabled tie can no longer occur.
    assert.deepEqual(
      EXTRA_TRAINS.map((t) => t.number).sort((a, b) => a - b),
      [13, 14, 15, 16, 17, 18, 19, 20, 21, 22],
    );
    assert.ok(EXTRA_TRAINS.every((e) => TIMETABLED_TRAINS.every((t) => t.number < e.number)));
  });

  it('gives every train a name and a speed class', () => {
    for (const t of [...TIMETABLED_TRAINS, ...EXTRA_TRAINS]) {
      assert.ok(t.name.length > 2, `train ${t.number} has no name`);
      assert.ok(t.speed === 'fast' || t.speed === 'slow', `train ${t.number} has no speed`);
    }
  });

  it('identifies the both-direction industry', () => {
    const houses = FREIGHT_PROFILES.filter(isFreightHouse).map((f) => f.kind);
    assert.deepEqual(houses.sort(), ['freightHouse']);
  });

  it('starts every industry at one car out and one loader', () => {
    // The design is far leaner than the placeholder: capacity grows via modifier cards.
    for (const f of FREIGHT_PROFILES) {
      assert.ok(f.baseOut <= 1, `${f.name} baseOut ${f.baseOut}`);
      assert.ok(f.baseIn <= 1, `${f.name} baseIn ${f.baseIn}`);
      assert.equal(f.baseLoaders, 1, `${f.name} loaders`);
    }
  });

  it('gives a facility capacity only in the directions it allows', () => {
    for (const f of FREIGHT_PROFILES) {
      const wantsOut = f.flow === 'outbound' || f.flow === 'both';
      const wantsIn = f.flow === 'inbound' || f.flow === 'both';
      assert.equal(f.baseOut > 0, wantsOut, `${f.name} outbound`);
      assert.equal(f.baseIn > 0, wantsIn, `${f.name} inbound`);
    }
  });

  it('records lockouts symmetrically', () => {
    // Column E of the sheet. Semantics are still open, but the data must be consistent.
    for (const f of FREIGHT_PROFILES) {
      for (const other of f.lockouts) {
        const o = FREIGHT_PROFILES.find((x) => x.kind === other)!;
        assert.ok(o.lockouts.includes(f.kind), `${f.kind} locks out ${other} but not vice versa`);
      }
    }
  });

  it('ties every modifier to at least one real host', () => {
    for (const m of MODIFIER_PROFILES) {
      assert.ok(m.hosts.length > 0, `${m.name} has no host`);
      for (const h of m.hosts) {
        if (h === 'office') continue;
        assert.ok(FREIGHT_PROFILES.some((f) => f.kind === h), `${m.name} names unknown host ${h}`);
      }
    }
  });

  it('makes only the Whistle Post a non-Control-Point', () => {
    for (const o of OFFICE_PROFILES) {
      assert.equal(o.isControlPoint, o.tier !== 'whistlePost', o.tier);
      assert.equal(o.isPassengerFacility, o.tier !== 'whistlePost', o.tier);
    }
  });

  it('scales A/D tracks 1/2/3/4 and slots above porter count', () => {
    assert.deepEqual(OFFICE_PROFILES.map((o) => o.adTracks), [1, 2, 3, 4]);
    // The design gives slots EQUAL to porters; the earlier guess was one too generous.
    for (const o of OFFICE_PROFILES) {
      if (o.tier === 'whistlePost') continue;
      assert.equal(o.passengerOut, o.porters, `${o.tier} green slots`);
      assert.equal(o.passengerIn, o.porters, `${o.tier} red slots`);
    }
  });

  it('forms an office pyramid so the strict upgrade sequence cannot stall', () => {
    // Assert the PROPERTY, not the literal counts. Upgrades are strictly sequential (Gap 3b), so
    // every tier must be at least as common as the one above it — otherwise players reach a rung
    // whose next card is scarcer than the one that got them there. Densities are provisional (Q12)
    // and expected to move again; the pyramid shape is what must survive.
    const inDeck = OFFICE_PROFILES.filter((o) => o.copiesInDeck > 0).map((o) => o.copiesInDeck);
    assert.ok(inDeck.length >= 2, 'there must be an upgrade ladder at all');
    for (let i = 1; i < inDeck.length; i++) {
      assert.ok(
        inDeck[i]! <= inDeck[i - 1]!,
        `tier ${i} has ${inDeck[i]} copies but the tier below it has ${inDeck[i - 1]}`,
      );
    }
    // The Whistle Post is the starting state, never a card.
    assert.equal(OFFICE_PROFILES.find((o) => o.tier === 'whistlePost')!.copiesInDeck, 0);
  });

  it('walks the upgrade sequence without skipping', () => {
    assert.equal(nextOfficeTier('whistlePost'), 'depot');
    assert.equal(nextOfficeTier('depot'), 'station');
    assert.equal(nextOfficeTier('station'), 'terminal');
    assert.equal(nextOfficeTier('terminal'), null);
  });

  it('supplies the full rolling stock roster', () => {
    // 80 pieces after the Gap 12 supply scale-up. Asserted against the constant rather than a
    // literal so the invariant is "the yard holds exactly the roster", not a number to re-edit.
    assert.equal(TOTAL_ROLLING_STOCK, 80);
    assert.equal(buildRollingStock().length, TOTAL_ROLLING_STOCK);
  });

  it('never demands more cars than the supply can furnish', () => {
    // card-reference.md §8 supply check, as an executable assertion.
    const supply = new Map(ROLLING_STOCK_SUPPLY.map((s) => [s.type, s.loaded + s.empty]));
    const demand = new Map<CarType, number>();
    for (const f of FREIGHT_PROFILES) {
      const need = (f.baseOut + f.baseIn) * f.copies;
      for (const t of f.carTypes) demand.set(t, (demand.get(t) ?? 0) + need);
    }
    for (const [type, need] of demand) {
      assert.ok(need <= supply.get(type)!, `${type}: demand ${need} > supply ${supply.get(type)}`);
    }
  });

  it('uses the Gap 10e revised victory targets', () => {
    assert.deepEqual(
      LENGTH_PROFILES.map((p) => [p.target, p.days]),
      [[10, 3], [20, 5], [45, 10]],
    );
    assert.equal(lengthProfile('standard').target, 20);
  });

  it('scales fixed supplies with player count', () => {
    assert.equal(mainlineCardCount(1), 2);
    assert.equal(mainlineCardCount(3), 4);
    assert.equal(crewTrayCount(1), 4);
    assert.equal(crewTrayCount(3), 6);
  });
});

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

describe('seeded RNG (component 7)', () => {
  it('is deterministic for a given seed', () => {
    const a = createRng(42);
    const b = createRng(42);
    const drawsA = Array.from({ length: 50 }, () => a.nextInt(1000));
    const drawsB = Array.from({ length: 50 }, () => b.nextInt(1000));
    assert.deepEqual(drawsA, drawsB);
  });

  it('differs between seeds', () => {
    const a = Array.from({ length: 20 }, ((r) => () => r.nextInt(1000))(createRng(1)));
    const b = Array.from({ length: 20 }, ((r) => () => r.nextInt(1000))(createRng(2)));
    assert.notDeepEqual(a, b);
  });

  it('rolls a D12 strictly within 1..12', () => {
    const rng = createRng(7);
    const seen = new Set<number>();
    for (let i = 0; i < 5000; i++) {
      const roll = rng.d12();
      assert.ok(roll >= 1 && roll <= 12, `roll out of range: ${roll}`);
      seen.add(roll);
    }
    assert.equal(seen.size, 12, 'every face should appear over 5000 rolls');
  });

  it('shuffles without mutating or losing elements', () => {
    const rng = createRng(99);
    const input = Array.from({ length: 52 }, (_, i) => i);
    const out = rng.shuffle(input);
    assert.deepEqual(input, Array.from({ length: 52 }, (_, i) => i), 'input mutated');
    assert.deepEqual([...out].sort((x, y) => x - y), input, 'elements lost');
    assert.notDeepEqual(out, input, 'shuffle was a no-op');
  });

  it('rejects an invalid bound', () => {
    const rng = createRng(1);
    assert.throws(() => rng.nextInt(0));
    assert.throws(() => rng.nextInt(-3));
  });
});

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

describe('game setup (component 2)', () => {
  it('constructs a solitaire game from a seed', () => {
    const g = newSolitaireGame();
    assert.equal(g.status, 'active');
    assert.equal(g.players.length, 1);
    assert.equal(g.clock.day, 1);
    assert.equal(g.clock.stage, 1);
    assert.equal(g.clock.phase, 'localOps');
    assert.equal(g.clock.superintendent, 0);
    assert.equal(g.clock.currentActor, 0);
  });

  it('is reproducible from the same seed and differs on another', () => {
    assert.deepEqual(newSolitaireGame(5).decks.homeOffice, newSolitaireGame(5).decks.homeOffice);
    assert.notDeepEqual(
      newSolitaireGame(5).decks.homeOffice,
      newSolitaireGame(6).decks.homeOffice,
    );
  });

  it('builds the MVP division: DP - ML - Office - ML - DP', () => {
    const g = newSolitaireGame();
    assert.deepEqual(
      g.division.nodes.map((n) => n.kind),
      ['divisionPoint', 'mainline', 'office', 'mainline', 'divisionPoint'],
    );
  });

  it('deals each Mainline card a terrain type', () => {
    // Terrain is what sets crossing time (Q1), so every Mainline node must carry a card kind.
    const g = newSolitaireGame();
    for (const node of g.division.nodes) {
      if (node.kind === 'mainline') {
        assert.ok(node.card, 'mainline node has no card type');
        assert.deepEqual(node.transits, []);
      }
    }
  });

  it('opens with the whole railroad as one Subdivision', () => {
    // §8 — every Office is a Whistle Post, which is not a Control Point.
    const g = newSolitaireGame();
    const subs = subdivisions(g);
    assert.equal(subs.length, 1, 'expected exactly one Subdivision at game start');
  });

  it('starts each player on a Whistle Post with three cards in a row', () => {
    const g = newSolitaireGame();
    const area = g.officeAreas.get(0)!;
    assert.equal(area.tier, 'whistlePost');
    assert.equal(area.grid.size, 3, 'Limits, Whistle Post, Limits');
    assert.equal(officeProfile(area.tier).adTracks, 1);
    assert.equal(area.adOccupancy.length, 0);
  });

  it('deals three cards and turns three Department slots face up', () => {
    const g = newSolitaireGame();
    assert.equal(g.decks.hands.get(0)!.length, 3);
    assert.equal(g.decks.departments.length, 3);
    assert.ok(g.decks.departments.every((c) => c !== null));
  });

  it('accounts for every card exactly once', () => {
    const g = newSolitaireGame();
    const all = [
      ...g.decks.homeOffice,
      ...g.decks.departments.filter((c): c is string => c !== null),
      ...g.decks.salvageYard,
      ...[...g.decks.hands.values()].flat(),
    ];
    // A solitaire deck omits the 22 opponent-directed cards (Q6), so it holds 118, not 140.
    assert.equal(all.length, SOLITAIRE_DECK_SIZE, 'cards lost or duplicated');
    assert.equal(new Set(all).size, SOLITAIRE_DECK_SIZE, 'duplicate card ids');
  });

  it('starts with no trains scheduled and none running', () => {
    // §4 — "no trains are officially running yet". This is the Day-1 revenue ramp (Gap 10e).
    const g = newSolitaireGame();
    assert.equal(g.timetable.length, 12);
    assert.ok(g.timetable.every((slot) => slot === null));
    assert.equal(g.trays.size, 0);
  });

  it('provides player-count + 3 crew trays', () => {
    assert.equal(newSolitaireGame().freeTrays.length, 4);
  });

  it('puts every rolling stock piece in the Division Yard', () => {
    const g = newSolitaireGame();
    assert.equal(g.yards.divisionYard.length, TOTAL_ROLLING_STOCK);
    assert.equal(g.yards.classificationYard.length, 0);
  });

  it('rejects a solitaire game with more than one player', () => {
    assert.throws(() =>
      createGame({ id: 'x', seed: 1, config: solitaireConfig, playerNames: ['a', 'b'] }),
    );
  });
});
