import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { makeRng } from './rng';
import { grantAllResearch } from './testkit';
import type { Command, Dir, EntityState, GameData, RecipeDef, Sim, SimEvent } from '../contracts';

import items from '../../data/items.json';
import machines from '../../data/machines.json';
import recipes from '../../data/recipes.json';
import tech from '../../data/tech.json';
import commissions from '../../data/commissions.json';

const DATA = { items, machines, recipes, tech, commissions } as unknown as GameData;

const N: Dir = 0, E: Dir = 1, S: Dir = 2;
const BELT_STEP = 2 / 30; // beltSpeed 2 tiles/sec at 30 tps
const BELT_SPACING_MIN = 0.45 - 1e-9;

// Assertions read the recipe graph rather than hard-coding it: LANE-DATA owns those
// numbers and they move. Mechanics are what this suite is actually pinning down.
const recipe = (id: string): RecipeDef => {
  const r = DATA.recipes.find((x) => x.id === id);
  if (!r) throw new Error(`test fixture drift: no recipe "${id}"`);
  return r;
};

function newSim(seed = 1337): Sim {
  const sim = createSim();
  sim.init(DATA, seed);
  // v4 gating locks the mosh reactor and friends; these tests build the M1 chain, which
  // is an endgame build. Placement gating gets its own tests in research.test.ts.
  grantAllResearch(sim, DATA);
  return sim;
}

const place = (def: string, x: number, y: number, dir: Dir = N): Command =>
  ({ kind: 'place', def, pos: { x, y }, dir });

function run(sim: Sim, ticks: number, sink?: SimEvent[]): void {
  for (let i = 0; i < ticks; i++) {
    sim.tick();
    const evs = sim.drainEvents();
    if (sink) for (const e of evs) sink.push(e);
  }
}

const entityAt = (sim: Sim, x: number, y: number): EntityState | undefined =>
  sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y);

const countCrafts = (evs: SimEvent[], id: string): number =>
  evs.filter((e) => e.kind === 'crafted' && e.recipe === id).length;

/** A miner and a power plant: the minimum viable factory for most of these tests. */
function seed(sim: Sim): void {
  sim.enqueue(place('decode-asic', 0, 10));
  sim.enqueue(place('seam-extractor', 0, 0));
}

describe('rng', () => {
  it('is reproducible from a seed and diverges across seeds', () => {
    const a = makeRng(99), b = makeRng(99), c = makeRng(100);
    const seqA = Array.from({ length: 8 }, () => a.next());
    const seqB = Array.from({ length: 8 }, () => b.next());
    const seqC = Array.from({ length: 8 }, () => c.next());
    expect(seqA).toEqual(seqB);
    expect(seqA).not.toEqual(seqC);
    for (const v of seqA) {
      expect(v).toBeGreaterThanOrEqual(0);
      expect(v).toBeLessThan(1);
    }
  });
});

describe('placement', () => {
  it('rejects overlapping and out-of-bounds footprints, keeps the legal ones', () => {
    const sim = newSim();
    const evs: SimEvent[] = [];
    sim.enqueue(place('demuxer', 0, 0));   // occupies (0,0)(1,0)(0,1)(1,1)
    sim.enqueue(place('demuxer', 1, 1));   // overlaps (1,1) -> dropped
    sim.enqueue(place('demuxer', 2, 0));   // free -> placed
    sim.enqueue(place('demuxer', 30, 30)); // ends exactly at the grid edge -> placed
    sim.enqueue(place('demuxer', 31, 31)); // would need (32,32) -> dropped
    sim.enqueue(place('nonexistent-machine', 20, 20)); // unknown def -> dropped
    run(sim, 1, evs);

    expect(sim.snapshot().entities.map((e) => e.pos)).toEqual([
      { x: 0, y: 0 }, { x: 2, y: 0 }, { x: 30, y: 30 },
    ]);
    expect(evs.filter((e) => e.kind === 'placed')).toHaveLength(3);
  });

  it('rotation swaps a non-square footprint and is refused when it would collide', () => {
    const sim = newSim();
    sim.enqueue(place('demosaicer', 0, 0)); // 3x2 -> (0..2, 0..1)
    sim.enqueue(place('demuxer', 0, 3));    // parks in the way of the rotated footprint
    run(sim, 1);
    sim.enqueue({ kind: 'rotate', pos: { x: 0, y: 0 } }); // would need (0..1, 0..2) -> free? no: 2x3
    run(sim, 1);
    // 3x2 rotated to face E becomes 2x3 -> needs (0..1, 0..2); (0,3) is clear of that, so it turns.
    expect(entityAt(sim, 0, 0)?.dir).toBe(E);

    // Now a genuine collision: something sitting inside the rotated footprint.
    const sim2 = newSim();
    sim2.enqueue(place('demosaicer', 0, 0)); // occupies (0..2, 0..1)
    sim2.enqueue(place('belt', 0, 2, N));    // inside the would-be rotated footprint
    run(sim2, 1);
    sim2.enqueue({ kind: 'rotate', pos: { x: 0, y: 0 } });
    run(sim2, 1);
    expect(entityAt(sim2, 0, 0)?.dir).toBe(N); // refused, stays put
  });

  it('defaults a machine to its first recipe and removes on command', () => {
    const sim = newSim();
    sim.enqueue(place('demuxer', 0, 0));
    run(sim, 1);
    expect(entityAt(sim, 0, 0)?.recipe).toBe(DATA.machines.find((m) => m.id === 'demuxer')!.recipes[0]);

    sim.enqueue({ kind: 'remove', pos: { x: 1, y: 1 } }); // any occupied tile works
    const evs: SimEvent[] = [];
    run(sim, 1, evs);
    expect(sim.snapshot().entities).toHaveLength(0);
    expect(evs.filter((e) => e.kind === 'removed')).toHaveLength(1);
  });

  it('only accepts recipes the machine can actually run', () => {
    const sim = newSim();
    sim.enqueue(place('quantizer', 0, 0));
    run(sim, 1);
    const id = entityAt(sim, 0, 0)!.id;

    sim.enqueue({ kind: 'setRecipe', entity: id, recipe: 'quantize-crime' });
    run(sim, 1);
    expect(entityAt(sim, 0, 0)!.recipe).toBe('quantize-crime');

    sim.enqueue({ kind: 'setRecipe', entity: id, recipe: 'mosh' }); // not a quantizer recipe
    run(sim, 1);
    expect(entityAt(sim, 0, 0)!.recipe).toBe('quantize-crime'); // unchanged
  });
});

describe('belts', () => {
  it('carries an item down a line at the belt speed on the def', () => {
    const sim = newSim();
    const beltXs = [2, 3, 4, 5, 6, 7];
    seed(sim);
    for (const x of beltXs) sim.enqueue(place('belt', x, 0, E));
    run(sim, 1);

    const lineIds = beltXs.map((x) => entityAt(sim, x, 0)!.id);
    const globalPos = (it: { entity: number; t: number }) => lineIds.indexOf(it.entity) + it.t;

    let firstSeen = -1;
    for (let i = 0; i < 200 && firstSeen < 0; i++) {
      run(sim, 1);
      if (sim.snapshot().beltItems.length > 0) firstSeen = sim.snapshot().tick;
    }
    expect(firstSeen).toBeGreaterThan(0);
    const startPos = globalPos(sim.snapshot().beltItems[0]);

    // The line dead-ends, so the lead item parks at t=1 on the last belt: 6 tiles of travel.
    let arrived = -1;
    for (let i = 0; i < 400 && arrived < 0; i++) {
      run(sim, 1);
      const lead = sim.snapshot().beltItems[0];
      if (lead.entity === lineIds[lineIds.length - 1] && lead.t >= 1) arrived = sim.snapshot().tick;
    }
    expect(arrived).toBeGreaterThan(0);

    const expected = Math.ceil((beltXs.length - startPos) / BELT_STEP);
    expect(arrived - firstSeen).toBeGreaterThanOrEqual(expected);
    expect(arrived - firstSeen).toBeLessThanOrEqual(expected + 1);
    expect(sim.snapshot().beltItems[0].t).toBe(1); // parked exactly at the lip
  });

  it('backs up cleanly when the line is full — no items lost, no items duplicated', () => {
    const sim = newSim();
    const beltXs = [2, 3, 4, 5];
    seed(sim);
    for (const x of beltXs) sim.enqueue(place('belt', x, 0, E)); // dead-ends at (6,0)

    const evs: SimEvent[] = [];
    run(sim, 2000, evs);
    const snap = sim.snapshot();

    const perBelt = new Map<number, number[]>();
    for (const it of snap.beltItems) {
      expect(it.t).toBeLessThanOrEqual(1 + 1e-9);
      const ts = perBelt.get(it.entity) ?? [];
      ts.push(it.t);
      perBelt.set(it.entity, ts);
    }
    for (const ts of perBelt.values()) {
      expect(ts.length).toBeLessThanOrEqual(2);
      ts.sort((a, b) => b - a);
      for (let i = 1; i < ts.length; i++) {
        expect(ts[i - 1] - ts[i]).toBeGreaterThanOrEqual(BELT_SPACING_MIN);
      }
    }

    // Conservation: every ore ever mined is on a belt or still held by the extractor.
    const perCraft = recipe('extract-mdat').outputs['mdat-ore'];
    const mined = countCrafts(evs, 'extract-mdat') * perCraft;
    const held = entityAt(sim, 0, 0)!.outputBuf['mdat-ore'] ?? 0;
    expect(mined).toBeGreaterThan(0);
    expect(snap.beltItems.length + held).toBe(mined);

    // A full line stalls the miner rather than deleting ore.
    expect(entityAt(sim, 0, 0)!.jammed).toBe('output full');
    expect(evs.filter((e) => e.kind === 'jammed')).toHaveLength(1); // once per stall, not per tick
  });

  it('merges two lines onto one without losing cargo', () => {
    const sim = newSim();
    sim.enqueue(place('decode-asic', 0, 10));
    sim.enqueue(place('seam-extractor', 0, 0));  // feeds (2,0) heading E
    sim.enqueue(place('seam-extractor', 0, 3));  // feeds (2,3) heading N, merging at (2,1)
    sim.enqueue(place('belt', 2, 3, N));
    sim.enqueue(place('belt', 2, 2, N));
    sim.enqueue(place('belt', 2, 1, N));
    sim.enqueue(place('belt', 2, 0, E));
    sim.enqueue(place('belt', 3, 0, E));
    sim.enqueue(place('shipper', 4, 0));

    const evs: SimEvent[] = [];
    run(sim, 900, evs);
    const perCraft = recipe('extract-mdat').outputs['mdat-ore'];
    const mined = countCrafts(evs, 'extract-mdat') * perCraft;
    const shipped = sim.snapshot().shippedTotal['mdat-ore'] ?? 0;
    const inFlight = sim.snapshot().beltItems.length;
    const held = (entityAt(sim, 0, 0)!.outputBuf['mdat-ore'] ?? 0)
      + (entityAt(sim, 0, 3)!.outputBuf['mdat-ore'] ?? 0);

    expect(shipped).toBeGreaterThan(0);
    expect(shipped + inFlight + held).toBe(mined); // nothing vanished at the merge
  });
});

describe('crafting', () => {
  it('consumes a full recipe of ore and yields exactly the recipe outputs', () => {
    const sim = newSim();
    seed(sim);
    sim.enqueue(place('belt', 2, 0, E));
    sim.enqueue(place('belt', 3, 0, E));
    sim.enqueue(place('demuxer', 4, 0));

    const demuxRecipe = recipe('demux-ore');
    let crafts = 0;
    const evs: SimEvent[] = [];
    for (let i = 0; i < 800 && crafts === 0; i++) {
      run(sim, 1, evs);
      crafts = countCrafts(evs, 'demux-ore');
    }
    expect(crafts).toBe(1);

    // No output belts here, so the yield sits in the buffer exactly as the recipe states.
    expect(entityAt(sim, 4, 0)!.outputBuf).toEqual(demuxRecipe.outputs);
    expect(countCrafts(evs, 'extract-mdat')).toBeGreaterThanOrEqual(demuxRecipe.inputs['mdat-ore']);
  });

  it('stalls with a reason once the output buffer is full', () => {
    const sim = newSim();
    seed(sim); // extractor with nowhere to put ore
    const evs: SimEvent[] = [];
    run(sim, 600, evs);

    const ext = entityAt(sim, 0, 0)!;
    const cap = recipe('extract-mdat').outputs['mdat-ore'] * 4;
    expect(ext.outputBuf['mdat-ore']).toBe(cap);
    expect(ext.jammed).toBe('output full');
    expect(evs.filter((e) => e.kind === 'jammed')).toHaveLength(1);
  });
});

describe('bandwidth', () => {
  it('halves everything when draw is twice gen', () => {
    const sim = newSim();
    const beltXs = [2, 3, 4, 5, 6, 7];
    sim.enqueue(place('decode-asic', 0, 10));   // gen 20
    sim.enqueue(place('seam-extractor', 0, 0)); // draw 2, +2 while extracting
    for (const x of beltXs) sim.enqueue(place('belt', x, 0, E)); // draw 0
    for (let i = 0; i < 9; i++) {               // 9 idle quantizers: draw 4 each = 36
      sim.enqueue(place('quantizer', 20 + (i % 3) * 3, ((i / 3) | 0) * 3));
    }

    const evs: SimEvent[] = [];
    let seen = false;
    for (let i = 0; i < 400 && !seen; i++) {
      run(sim, 1, evs);
      seen = sim.snapshot().beltItems.length > 0;
    }
    expect(seen).toBe(true);

    const snap = sim.snapshot();
    expect(snap.bandwidth.gen).toBe(20);
    expect(snap.bandwidth.draw).toBe(40);
    expect(snap.bandwidth.brownout).toBe(true);
    expect(evs.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(1);

    const lineIds = beltXs.map((x) => entityAt(sim, x, 0)!.id);
    const globalPos = () => {
      const it = sim.snapshot().beltItems[0];
      return lineIds.indexOf(it.entity) + it.t;
    };
    const before = globalPos();
    run(sim, 30);
    // 30 ticks at half of 2 tiles/sec is exactly 1 tile.
    expect(globalPos() - before).toBeCloseTo(1, 6);
  });

  it('reports no brownout when generation covers draw', () => {
    const sim = newSim();
    seed(sim);
    run(sim, 10);
    expect(sim.snapshot().bandwidth.brownout).toBe(false);
    expect(sim.snapshot().bandwidth.draw).toBe(4); // 2 idle + 2 while extracting
    expect(sim.snapshot().bandwidth.gen).toBe(20);
  });

  it('counts compression as generation — the quantizer pays for itself', () => {
    const q = recipe('quantize');
    expect(q.bandwidth).toBeLessThan(0); // guards the premise of this test

    const sim = newSim();
    seed(sim);
    sim.enqueue(place('belt', 2, 0, E));
    sim.enqueue(place('demuxer', 3, 0));
    sim.enqueue(place('belt', 5, 0, E));
    sim.enqueue(place('quantizer', 6, 0));

    let genWhileCompressing = 0;
    for (let i = 0; i < 900; i++) {
      run(sim, 1);
      if (entityAt(sim, 6, 0)!.progress > 0) {
        genWhileCompressing = sim.snapshot().bandwidth.gen;
        break;
      }
    }
    // 20 from the ASIC plus the quantizer's negative bandwidth handed back as supply.
    expect(genWhileCompressing).toBe(20 - q.bandwidth);
  });
});

// ---------------------------------------------------------------- the M1 chain
//
// MELT, from raw ore, using the real codex graph:
//   ore -> demuxer -> luma  -> quantizer -> coefficient packs ─┐
//                  -> slurry -> subsampler 4:2:2 -> 4:2:0 ─────┴-> DCT press -> anchor slab -┐
//                  -> mv-flux -> p-caster -> delta wafers ──────────────────────────────────-┴-> GOP assembler
//                                                                          GOP crate -> mosh reactor -> MELT
// HF dust gets its own uplink because nothing downstream will take it.

function meltChain(): Command[] {
  const c: Command[] = [
    place('software-decoder', -30, -10),
    place('software-decoder', -27, -10),
    place('software-decoder', -24, -10),

    place('seam-extractor', -30, 0),
    place('seam-extractor', -30, -3),
    place('seam-extractor', -30, 3),
    place('belt', -28, -2, S), place('belt', -28, -1, S),   // north miner joins the spine
    place('belt', -28, 3, N), place('belt', -28, 2, N), place('belt', -28, 1, N),
    place('belt', -28, 0, E), place('belt', -27, 0, E),
    place('demuxer', -26, 0),

    place('belt', -24, 0, E), place('belt', -23, 0, E),     // luma
    place('quantizer', -22, 0),
    place('belt', -20, 0, E), place('belt', -19, 0, E),     // coefficient packs
    place('dct-press', -18, 0),
    place('belt', -21, 2, S), place('belt', -21, 3, E),     // hf dust
    place('shipper', -20, 2),

    place('belt', -25, -1, N), place('belt', -25, -2, N), place('belt', -25, -3, N), // mv-flux
    place('p-caster', -26, -5),

    place('belt', -26, 2, S), place('belt', -26, 3, S), place('belt', -26, 4, S),    // slurry
    place('subsampler', -27, 5),
    place('belt', -25, 5, E), place('belt', -24, 5, E),
    place('subsampler', -23, 5),
    place('belt', -21, 5, E), place('belt', -20, 5, E), place('belt', -19, 5, E),
    place('belt', -18, 5, E), place('belt', -17, 5, N), place('belt', -17, 4, N),
    place('belt', -17, 3, N),

    place('belt', -15, 0, E), place('belt', -14, 0, E),     // anchor slabs
    place('gop-assembler', -13, 0),
    place('belt', -10, 0, E), place('belt', -9, 0, E),      // GOP crates
    place('mosh-reactor', -8, 0),
    place('belt', -5, 0, E),                                // melt + recovered slabs
    place('shipper', -4, 0),
  ];
  for (let x = -24; x <= -13; x++) c.push(place('belt', x, -4, E)); // delta wafer trunk
  for (let y = -4; y <= -1; y++) c.push(place('belt', -12, y, S));
  return c;
}

/** Places the chain and dials in the two machines whose default recipe isn't the one we want. */
function buildMeltChain(sim: Sim): void {
  for (const cmd of meltChain()) sim.enqueue(cmd);
  sim.tick();
  sim.drainEvents();
  sim.enqueue({ kind: 'setRecipe', entity: entityAt(sim, -18, 0)!.id, recipe: 'press-anchor-slab' });
  sim.enqueue({ kind: 'setRecipe', entity: entityAt(sim, -23, 5)!.id, recipe: 'subsample-420' });
  sim.tick();
  sim.drainEvents();
}

describe('determinism', () => {
  it('two sims with the same seed and commands agree after 500 ticks', () => {
    const a = newSim(4242);
    const b = newSim(4242);
    buildMeltChain(a);
    buildMeltChain(b);
    run(a, 500);
    run(b, 500);

    expect(a.snapshot().beltItems.length).toBeGreaterThan(0); // not vacuously equal
    expect(a.snapshot().entities.length).toBeGreaterThan(0);
    expect(a.snapshot()).toEqual(b.snapshot());
  });

  it('replays identically when commands arrive mid-run', () => {
    const script: Array<[number, Command]> = meltChain().map((c, i) => [i * 3, c]);
    const play = (): Sim => {
      const sim = newSim(7);
      for (let t = 0; t < 900; t++) {
        for (const [at, cmd] of script) if (at === t) sim.enqueue(cmd);
        sim.tick();
        sim.drainEvents();
      }
      return sim;
    };
    expect(play().snapshot()).toEqual(play().snapshot());
  });
});

describe('shipping', () => {
  it('ships whatever it is handed', () => {
    const sim = newSim();
    seed(sim);
    sim.enqueue(place('belt', 2, 0, E));
    sim.enqueue(place('belt', 3, 0, E));
    sim.enqueue(place('shipper', 4, 0));

    const evs: SimEvent[] = [];
    run(sim, 300, evs);
    expect(sim.snapshot().shippedTotal['mdat-ore']).toBeGreaterThan(0);
    expect(evs.filter((e) => e.kind === 'shipped')).not.toHaveLength(0);
    expect(evs.filter((e) => e.kind === 'commissionDone')).toHaveLength(0); // wants melt, not ore
  });

  it('runs the whole M1 chain: raw ore in, MELT on THE SCREEN', () => {
    const sim = newSim();
    buildMeltChain(sim);

    const evs: SimEvent[] = [];
    run(sim, 4000, evs);
    const snap = sim.snapshot();

    expect(snap.shippedTotal['melt'] ?? 0).toBeGreaterThanOrEqual(1);
    // Routing did its job: the byproduct went to its own uplink, not into the reactor.
    expect(snap.shippedTotal['hf-dust'] ?? 0).toBeGreaterThan(0);
    // And the reactor's recovered anchor slabs came back out to be sold.
    expect(snap.shippedTotal['anchor-slab'] ?? 0).toBeGreaterThan(0);

    // The commission ladder is DATA's to reorder — and they have, twice. Assert the RULE
    // (shipping what the active order wants credits it, and closing advances the queue)
    // rather than which order happens to be first this week.
    const active = DATA.commissions.find((c) => c.id === snap.activeCommission);
    expect(active, 'a commission should always be active while the queue is non-empty')
      .toBeDefined();
    for (const [item, want] of Object.entries(active!.wants)) {
      const credited = snap.commissionProgress[item] ?? 0;
      expect(credited).toBeLessThanOrEqual(want);      // never banks past the cost
      expect(credited).toBeLessThanOrEqual(snap.shippedTotal[item] ?? 0); // only real ships
    }
    for (const e of evs.filter((x) => x.kind === 'commissionDone')) {
      expect(DATA.commissions.some((c) => c.id === (e as { commission: string }).commission))
        .toBe(true);
    }
  });
});

describe('pausing', () => {
  it('freezes sim time but still accepts building', () => {
    const sim = newSim();
    seed(sim);
    run(sim, 60);
    const before = sim.snapshot().tick;

    sim.enqueue({ kind: 'setPaused', paused: true });
    run(sim, 60);
    expect(sim.snapshot().tick).toBe(before);
    expect(sim.snapshot().paused).toBe(true);

    sim.enqueue(place('belt', 2, 0, E));
    run(sim, 1);
    expect(entityAt(sim, 2, 0)).toBeDefined();

    sim.enqueue({ kind: 'setPaused', paused: false });
    run(sim, 10);
    expect(sim.snapshot().tick).toBe(before + 10);
  });
});
