/**
 * A real research run: raw ore in one end, a completed tech out the other, using nothing
 * but DATA's own machines, recipes and tech tree. No fixture, no granted state.
 *
 * This exists because the tree was, earlier this round, impossible to enter: there was no
 * lab, and every science pack was made on the artifact-bottler, which was itself locked
 * behind a tech costing 10 analog-packs. DATA fixed both mid-round. This test is the proof
 * that the fix holds, and the tripwire if the bootstrap ever closes over again.
 *
 *   ore -> demuxer -+-> luma  -> quantizer -> coefficient packs -> [split] -+-> crime  -> bricks -+
 *                   |                                                       +-> ringing -> halos -+
 *                   +-> chroma slurry ------------------------------------------------------------+
 *                   +-> mv-flux -> uplink                                                          |
 *                                        bricks + halos + slurry -> artifact bottler -> SPATIAL PACK
 *                                                                   -> archaeology lab -> tech
 */
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import type { Command, Dir, GameData, Sim, SimEvent, TechDef } from '../contracts';

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

const DATA = { items, machines, recipes, tech: techJson, commissions } as unknown as GameData;
const N: Dir = 0, E: Dir = 1, S: Dir = 2, W: Dir = 3;

/** The cheapest tech payable in packs the factory below can actually make. */
function cheapestSpatialTech(): TechDef | undefined {
  return DATA.tech
    .filter((t) => Object.keys(t.cost).length === 1 && t.cost['spatial-pack'] > 0)
    .sort((a, b) => a.cost['spatial-pack'] - b.cost['spatial-pack'])[0];
}

function packFactory(): Command[] {
  const cmds: Command[] = [];
  const P = (def: string, x: number, y: number, dir: Dir = N) =>
    cmds.push({ kind: 'place', def, pos: { x, y }, dir });
  const b = (x: number, y: number, dir: Dir) => P('belt', x, y, dir);
  const runX = (x0: number, x1: number, y: number, dir: Dir) => {
    const s = x0 <= x1 ? 1 : -1;
    for (let x = x0; ; x += s) { b(x, y, dir); if (x === x1) break; }
  };
  const runY = (y0: number, y1: number, x: number, dir: Dir) => {
    const s = y0 <= y1 ? 1 : -1;
    for (let y = y0; ; y += s) { b(x, y, dir); if (y === y1) break; }
  };
  const recipeAt = (x: number, y: number, recipe: string) =>
    cmds.push({ kind: 'setRecipeAt', pos: { x, y }, recipe });

  for (let i = 0; i < 4; i++) P('decode-asic', -30 + i * 3, -8);

  // ore
  P('seam-extractor', -30, 0);
  P('seam-extractor', -30, -3);
  P('seam-extractor', -30, 3);
  runY(-2, -1, -28, S);
  runY(3, 1, -28, N);
  runX(-28, -27, 0, E);
  P('demuxer', -26, 0);

  // mv-flux has no consumer here — sell it, or the demuxer jams on its own byproduct
  runY(-1, -2, -25, N);
  P('shipper', -26, -4);

  // luma -> coefficient packs
  runX(-24, -23, 0, E);
  P('quantizer', -22, 0);            // 'quantize' is the default recipe
  runY(-1, -2, -22, N);              // hf dust -> uplink
  P('shipper', -23, -4);
  b(-20, 0, E);
  P('lane-splitter', -19, 0);        // coefficient packs: bricks one way, halos the other

  // 1 pack of bricks per craft vs 3 coefficient packs per halo — the splitter's
  // skip-when-blocked does the balancing, as ever.
  b(-18, 0, E);
  P('quantizer', -17, 0);
  recipeAt(-17, 0, 'quantize-crime'); // -> macroblock bricks
  runY(-1, -2, -17, N);
  P('shipper', -18, -4);
  runX(-15, -14, 0, E);               // bricks -> bottler

  runY(1, 2, -19, S);
  P('quantizer', -20, 3);
  recipeAt(-20, 3, 'skim-ringing');   // -> ringing halos
  runX(-21, -22, 3, W);               // its hf dust -> uplink
  P('shipper', -24, 2);
  runX(-18, -15, 3, E);               // halos -> bottler
  runY(3, 2, -14, N);
  b(-14, 1, E);

  // chroma slurry -> bottler, the long way round below the works
  runY(2, 5, -26, S);
  b(-26, 6, E);
  runX(-25, -14, 6, E);
  b(-13, 6, N);
  runY(5, 2, -13, N);

  P('artifact-bottler', -13, 0);
  recipeAt(-13, 0, 'bottle-spatial-pack');
  runX(-11, -10, 0, E);
  P('archaeology-lab', -9, 0);
  return cmds;
}

describe('a real research run, on DATA’s own tree', () => {
  it('turns ore into a science pack and a science pack into a tech', () => {
    const target = cheapestSpatialTech();
    expect(target, 'DATA has no single-cost spatial-pack tech to aim at').toBeDefined();

    const sim: Sim = createSim();
    sim.init(DATA, 1337);
    // Nothing granted: every machine below must be legal from a cold start.
    expect(sim.snapshot().research!.unlocked).toEqual([]);

    for (const c of packFactory()) sim.enqueue(c);
    sim.enqueue({ kind: 'setResearch', tech: target!.id });

    const evs: SimEvent[] = [];
    let done = -1;
    for (let i = 0; i < 12000 && done < 0; i++) {
      sim.tick();
      for (const e of sim.drainEvents()) {
        evs.push(e);
        if (e.kind === 'researched') done = e.tick;
      }
    }

    // Every machine the layout asked for was legal from a cold start.
    expect(sim.snapshot().entities.find((e) => e.def === 'archaeology-lab')).toBeDefined();
    expect(sim.snapshot().entities.find((e) => e.def === 'artifact-bottler')).toBeDefined();
    // The ladder ran, rung by rung, on real recipes.
    for (const r of ['demux-ore', 'quantize', 'quantize-crime', 'skim-ringing', 'bottle-spatial-pack']) {
      expect(evs.filter((e) => e.kind === 'crafted' && e.recipe === r).length, `${r} never ran`)
        .toBeGreaterThan(0);
    }
    expect(done, `${target!.id} never completed`).toBeGreaterThan(0);
    expect(sim.snapshot().research!.unlocked).toEqual([target!.id]);
    expect(sim.snapshot().research!.active).toBeNull();
  });

  it('opens what the tech promised: a recipe refused before is accepted after', () => {
    const target = cheapestSpatialTech()!;
    const unlockedRecipe = target.unlocks.find((u) =>
      DATA.recipes.some((r) => r.id === u))!;
    expect(unlockedRecipe, `${target.id} unlocks no recipe to test with`).toBeDefined();
    const owner = DATA.recipes.find((r) => r.id === unlockedRecipe)!.machine;

    const sim: Sim = createSim();
    sim.init(DATA, 1337);
    sim.enqueue({ kind: 'place', def: owner, pos: { x: 0, y: 0 }, dir: N });
    sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe });
    sim.tick();
    const before = sim.snapshot().entities.find((e) => e.pos.x === 0)!;
    expect(before.recipe).not.toBe(unlockedRecipe); // silently refused

    for (const c of packFactory()) sim.enqueue(c);
    sim.enqueue({ kind: 'setResearch', tech: target.id });
    for (let i = 0; i < 12000; i++) {
      sim.tick();
      if (sim.drainEvents().some((e) => e.kind === 'researched')) break;
    }
    expect(sim.snapshot().research!.unlocked).toEqual([target.id]);

    sim.enqueue({ kind: 'setRecipeAt', pos: { x: 0, y: 0 }, recipe: unlockedRecipe });
    sim.tick();
    expect(sim.snapshot().entities.find((e) => e.pos.x === 0)!.recipe).toBe(unlockedRecipe);
  });
});
