/**
 * Build order steps 5-6 — bots play legal games, and the harness produces stable numbers.
 * See architecture/components.md §4.
 */

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

import { pump } from '../src/engine/advance.ts';
import { createGame } from '../src/engine/setup.ts';
import type { GameConfig, GameState } from '../src/engine/state.ts';
import { developerBot, playGame, randomBot } from '../src/sim/bot.ts';
import { simulate } from '../src/sim/harness.ts';
import { record } from '../src/sim/replay.ts';
import { anomalies, strategyBuckets } from '../src/sim/stats.ts';

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

const game = (seed: number): GameState =>
  createGame({ id: `g${seed}`, seed, config, playerNames: ['bot'] });

describe('bots (component 17)', () => {
  it('plays only legal actions — playGame throws otherwise', () => {
    for (const seed of [1, 2, 3, 5, 8, 13]) {
      const r = playGame(game(seed), developerBot, pump);
      assert.ok(r.finished, `seed ${seed} did not finish`);
    }
  });

  it('the random control also plays only legal actions', () => {
    for (const seed of [21, 34, 55]) {
      const r = playGame(game(seed), randomBot(seed), pump);
      assert.ok(r.finished, `seed ${seed} did not finish`);
    }
  });

  it('develops the railroad rather than idling', () => {
    // Guards the class of bug where every game "completes" while nothing happens.
    const r = playGame(game(42), developerBot, pump);
    assert.ok(r.cardsPlayed > 0, 'no cards played');
    assert.ok(r.days > 1, 'game did not span a Day');
  });

  it('is reproducible from a seed', () => {
    const a = playGame(game(99), developerBot, pump);
    const b = playGame(game(99), developerBot, pump);
    assert.deepEqual(a.revenue, b.revenue);
    assert.deepEqual(a.outcome, b.outcome);
    assert.equal(a.turns, b.turns);
  });
});

describe('trains complete their runs (regression)', () => {
  it('does not leave trains parked at an Office forever', () => {
    // REGRESSION. `moveTrain` originally handled only Division Point and Mainline positions, so a
    // train that arrived at an Office never departed. It held its A/D track permanently and every
    // train behind it collided — 3.7 collisions per game, mean revenue −17, zero wins.
    //
    // A run of games should now show collisions as the exception, not the rule.
    const report = simulate({
      games: 40,
      length: 'short',
      mode: 'solitaire',
      players: ['bot'],
      policy: developerBot,
    });
    assert.ok(
      report.collisionsPerGame.median <= 1,
      `median ${report.collisionsPerGame.median} collisions/game — trains are piling up again`,
    );
  });

  it('keeps A/D occupancy in step with where trains actually are', () => {
    // The other half of the same bug: adOccupancy was only ever appended to, so an Office looked
    // permanently full once any train had visited.
    const s = game(7);
    playGame(s, developerBot, pump);
    for (const [owner, area] of s.officeAreas) {
      for (const id of area.adOccupancy) {
        const tray = s.trays.get(id);
        assert.ok(tray, `A/D track holds ${id}, which no longer exists`);
        assert.equal(tray.position.at, 'grid', `${id} is recorded at an Office but is elsewhere`);
        if (tray.position.at === 'grid') {
          assert.equal(tray.position.owner, owner);
        }
      }
    }
  });
});

describe('balance harness (component 18)', () => {
  it('produces a stable report over many games', () => {
    const report = simulate({
      games: 25,
      length: 'short',
      mode: 'solitaire',
      players: ['bot'],
      policy: developerBot,
    });
    assert.equal(report.games, 25);
    assert.equal(report.finished, 25, 'every game must reach an outcome');
    assert.ok(report.daysPlayed.mean > 0);
  });

  it('is deterministic — the same options give the same report', () => {
    const opts = {
      games: 10,
      length: 'short' as const,
      mode: 'solitaire' as const,
      players: ['bot'],
      policy: developerBot,
    };
    assert.deepEqual(simulate(opts), simulate(opts));
  });

  it('shows the heuristic bot outperforming the random control', () => {
    // If the policy is worth anything it must beat picking uniformly at random.
    const base = { games: 60, length: 'short' as const, mode: 'solitaire' as const, players: ['bot'] };
    const smart = simulate({ ...base, policy: developerBot });
    const dumb = simulate({ ...base, policy: randomBot(4242) });
    assert.ok(
      smart.revenuePerPlayer.mean > dumb.revenuePerPlayer.mean,
      `heuristic ${smart.revenuePerPlayer.mean.toFixed(1)} did not beat random ${dumb.revenuePerPlayer.mean.toFixed(1)}`,
    );
  });
});

describe('the revenue chain works end to end (regression)', () => {
  it('spots cars on industry tracks', () => {
    // REGRESSION. Cars dropped at a facility went into the card's `standing` list, while §9.3's
    // loading rule looked at `industryTrack`. Across 50 games ZERO cars were ever spotted and
    // freight revenue was structurally zero. Win rate went 0% -> ~35% when these were unified.
    let spotted = 0;
    for (const seed of [1, 2, 3, 4, 5, 6, 7, 8]) {
      const s = createGame({ id: 'g', seed, config: { ...config, length: 'standard' }, playerNames: ['bot'] });
      playGame(s, developerBot, pump);
      for (const card of s.officeAreas.get(0)!.grid.values()) {
        if (card.facility?.kind === 'freight') spotted += card.facility.industryTrack.cars.length;
      }
    }
    assert.ok(spotted > 0, 'no car ever reached an industry track across eight games');
  });

  it('instantiates a real Facility when a freight card is placed', () => {
    // REGRESSION. Placed facility cards were built with `facility: null` — inert, unworkable.
    //
    // Checked across several seeds: the design's deck holds only 9 industries in 115 cards, so a
    // single game may legitimately never draw one. That dilution is itself worth knowing — under
    // the old 52-card placeholder it was 10 in 52.
    let placed = 0;
    for (const seed of [11, 23, 47, 91, 137, 211]) {
      const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
      playGame(s, developerBot, pump);
      for (const card of s.officeAreas.get(0)!.grid.values()) {
        if (card.geometry.kind !== 'facility') continue;
        placed++;
        assert.ok(card.facility, 'a placed facility card has no Facility record');
        assert.ok(card.facility.laborers > 0, 'facility has no Laborers');
      }
    }
    assert.ok(placed > 0, 'no facility was placed across six campaign games');
  });

  it('earns freight revenue, not just passenger revenue', () => {
    // Asserts the FREIGHT chain specifically. An earlier version asserted `wins > 0`, which passed
    // only because unloads were mis-scored as completed loads after one Laborer action instead of
    // four. Correcting that dropped mean revenue from 24.8 to ~4.6 and the win rate to zero, so
    // "did anyone win" is no longer a safe proxy for "does freight work".
    const report = simulate({
      games: 40,
      length: 'standard',
      mode: 'solitaire',
      players: ['bot'],
      policy: developerBot,
    });
    const freight = report.perGame.reduce((n, g) => n + g.revenue.freightLoad, 0);
    assert.ok(freight > 0, 'no freight load completed across 40 games');
  });

  it('grows the Office Area downward from the Running Track (Q7)', () => {
    // The card art puts the through-track at the top edge, so Secondary Track hangs beneath it.
    // Nothing should ever be built above row 0.
    const rows = new Set<number>();
    for (const seed of [3, 9, 27, 81]) {
      const s = createGame({ id: 'g', seed, config: { ...config, length: 'campaign' }, playerNames: ['bot'] });
      playGame(s, developerBot, pump);
      for (const key of s.officeAreas.get(0)!.grid.keys()) rows.add(Number(key.split(',')[0]));
    }
    assert.ok([...rows].some((r) => r < 0), 'nothing was ever built below the Running Track');
    assert.ok(![...rows].some((r) => r > 0), 'something was built ABOVE the Running Track');
  });
});

describe('end-of-game statistics', () => {
  it('accounts for revenue by source', () => {
    const report = simulate({
      games: 20, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
    });
    for (const g of report.perGame) {
      const gross =
        g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain;
      assert.ok(gross >= 0, 'gross revenue cannot be negative');
      assert.ok(g.freightShare >= 0 && g.freightShare <= 1, 'freight share out of range');
    }
  });

  it('reports no anomalies — every rule in the engine is reachable', () => {
    // THE POINT OF THIS MODULE. Every serious bug so far was an unreachable rule: a pipeline with
    // no entry point, a facility that could not be worked, a car that could not be spotted. If an
    // expected event stops firing, something has become unreachable again.
    const report = simulate({
      games: 60, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
    });
    const found = anomalies(report.perGame);
    const never = found.filter((a) => a.severity === 'never');
    assert.deepEqual(
      never.map((a) => a.what),
      [],
      `unreachable rules: ${never.map((a) => `${a.what} (${a.detail})`).join('; ')}`,
    );
  });

  it('buckets games by strategy without losing any', () => {
    const report = simulate({
      games: 30, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
    });
    const scored = report.perGame.filter(
      (g) => g.revenue.freightLoad + g.revenue.passengerBoard + g.revenue.passengerDetrain > 0,
    );
    const bucketed = strategyBuckets(report.perGame).reduce((n, b) => n + b.games, 0);
    assert.equal(bucketed, scored.length, 'a scoring game fell outside every bucket');
  });
});

describe('switching accomplishes something (regression)', () => {
  it('does not shuttle the crew back and forth to no purpose', () => {
    // REGRESSION. The bot chose to SWITCH whenever a train stood at the Office, whether or not
    // there was anything to switch — then, having nothing useful to do, took the first legal move
    // repeatedly and burned all six Moves oscillating between two cells. A passenger train needs
    // no switching at all (§9.2 works coaches straight off the A/D track), so an entire Local
    // Operations action was wasted.
    const rec = record(1234, 'standard');

    let worstRun = 0;
    let run = 0;
    let lastFrom: string | null = null;

    for (const f of rec.frames) {
      const move = f.lines.find((l) => /^CREW moved/.test(l.text));
      const didWork = f.lines.some((l) => /Dropped|Coupled/.test(l.text));
      if (!move || didWork) {
        run = 0;
        lastFrom = null;
        continue;
      }
      const m = /\((-?\d+),(-?\d+)\) → \((-?\d+),(-?\d+)\)/.exec(move.text);
      if (!m) continue;
      const from = `${m[1]},${m[2]}`;
      const to = `${m[3]},${m[4]}`;
      // An oscillation is a move that lands exactly where the previous move started.
      run = lastFrom === to ? run + 1 : 0;
      worstRun = Math.max(worstRun, run);
      lastFrom = from;
    }

    assert.ok(worstRun < 3, `crew oscillated ${worstRun + 1} times without doing any work`);
  });

  it('does not spend its whole Local Operations budget on motion', () => {
    // The absolute ratio is no longer a fair test: with only 9 industries in 115 cards there is
    // often nothing to switch, so `work` is legitimately near zero. What still must hold is that
    // the crew is not burning all six Moves every Stage — the shuttling bug.
    const report = simulate({
      games: 40, length: 'standard', mode: 'solitaire', players: ['bot'], policy: developerBot,
    });
    const moves = report.perGame.reduce((n, g) => n + g.actions.moves, 0) / report.perGame.length;
    assert.ok(moves < 60, `${moves.toFixed(0)} Moves per game suggests aimless shuttling`);
  });
});

describe('measurement discipline', () => {
  it('the observer sees exactly the events the log records', () => {
    // REGRESSION, against a measurement bug rather than a game bug. Events reach the log from TWO
    // sources — the phase driver (`pump`) and player intents (`applyIntent`). An ad-hoc probe that
    // watched only the first reported "60 of 60 games never upgraded past Whistle Post" while the
    // Office tier histogram from the same run plainly showed Depots, Stations and Terminals.
    //
    // A wrong measurement is worse than a missing one: it gets believed and acted on. This asserts
    // the observer hook cannot drift from the log, so probes have one trustworthy way in and no
    // reason to hand-roll the drive loop again.
    const seen: string[] = [];
    const s = createGame({ id: 'obs', seed: 4242, config: { ...config, length: 'standard' }, playerNames: ['b'] });
    const out = playGame(s, developerBot, pump, 50_000, (e) => seen.push(e.type));

    assert.ok(out.events.length > 0, 'a finished game must produce events');
    assert.deepEqual(seen, out.events.map((e) => e.type));
  });

  it('records office upgrades where a probe can actually find them', () => {
    // The upgrade arrives via applyIntent, not pump — the exact branch the broken probe missed.
    // Asserted across several seeds because a single game may never draw a Depot card.
    let upgrades = 0;
    for (let seed = 0; seed < 25; seed++) {
      const s = createGame({ id: `u${seed}`, seed, config: { ...config, length: 'standard' }, playerNames: ['b'] });
      const out = playGame(s, developerBot, pump);
      upgrades += out.events.filter((e) => e.type === 'officeUpgraded').length;
    }
    assert.ok(upgrades > 0, 'no office upgrade was visible in the event log across 25 games');
  });
});
