/**
 * Research v1 and the v4 gating rule.
 *
 * The mechanics run on a fixture; the gating rule is checked against DATA's real tech.json.
 * The fixture exists so these tests pin the RULE rather than DATA's numbers — which moved
 * twice while this round was being written. For the same path proved end-to-end on the real
 * tree, with a real lab and real science packs, see realtech.test.ts.
 */
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { grantResearch } from './testkit';
import { RESEARCH_TICKS_PER_PACK } from './constants';
import type {
  Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, 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 REAL = { items, machines, recipes, tech: techJson, commissions } as unknown as GameData;

const N: Dir = 0, E: Dir = 1, S: Dir = 2;
const item = (id: string): ItemDef => ({ id, name: id, codex: 'fixture', tier: 0, color: '#fff' });
const mach = (m: Partial<MachineDef> & Pick<MachineDef, 'id' | 'kind'>): MachineDef => ({
  name: m.id, codex: 'fixture', footprint: { x: 1, y: 1 }, recipes: [], powerDraw: 0,
  asset: 'fixture', ...m,
});
const rec = (r: Partial<RecipeDef> & Pick<RecipeDef, 'id' | 'machine' | 'outputs'>): RecipeDef => ({
  inputs: {}, ticks: 1, bandwidth: 0, ...r,
});
const tech = (t: TechDef): TechDef => t;

/**
 * A tree that can actually be entered: the pack press is ungated, so packs exist before
 * any research does. (This is exactly the property DATA's real tree is missing.)
 */
const FIXTURE: GameData = {
  items: ['rock', 'pack', 'gold'].map(item),
  machines: [
    mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
    mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }),
    mach({ id: 'ship', kind: 'shipper' }),
    mach({ id: 'packer', kind: 'crafter', recipes: ['press-pack'] }),       // ungated
    mach({ id: 'lab', kind: 'lab' }),                                        // ungated
    mach({ id: 'split', kind: 'splitter' }),
    mach({ id: 'goldworks', kind: 'crafter', recipes: ['smelt-gold'] }),     // GATED
    mach({ id: 'other', kind: 'crafter', recipes: ['plain', 'fancy'] }),     // recipe 'fancy' GATED
  ],
  recipes: [
    rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 30 }),
    rec({ id: 'press-pack', machine: 'packer', inputs: { rock: 1 }, outputs: { pack: 1 }, ticks: 5 }),
    rec({ id: 'smelt-gold', machine: 'goldworks', inputs: { rock: 1 }, outputs: { gold: 1 }, ticks: 5 }),
    rec({ id: 'plain', machine: 'other', inputs: { rock: 1 }, outputs: { gold: 1 }, ticks: 5 }),
    rec({ id: 'fancy', machine: 'other', inputs: { rock: 2 }, outputs: { gold: 2 }, ticks: 5 }),
  ],
  tech: [
    tech({ id: 'metallurgy', era: 'reel', cost: { pack: 3 }, unlocks: ['goldworks', 'smelt-gold'] }),
    tech({ id: 'flourish', era: 'reel', cost: { pack: 2 }, unlocks: ['fancy'] }),
  ],
  commissions: [{ id: 'c1', flavor: 'gold', wants: { gold: 99 }, rewardBandwidth: 1 }],
};

function newSim(data: GameData = FIXTURE, seed = 1): Sim {
  const sim = createSim();
  sim.init(data, seed);
  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 at = (sim: Sim, x: number, y: number) =>
  sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y);

/** mine -> packer -> lab, plus somewhere for the packs to be counted. */
function packLine(sim: Sim): void {
  sim.enqueue(place('mine', 0, 0));
  sim.enqueue(place('belt', 1, 0, E));
  sim.enqueue(place('packer', 2, 0));
  sim.enqueue(place('belt', 3, 0, E));
  sim.enqueue(place('lab', 4, 0));
}

describe('gating', () => {
  it('locks every id a tech names and leaves everything else alone', () => {
    const sim = newSim();
    sim.enqueue(place('goldworks', 5, 5)); // named by metallurgy.unlocks -> dropped
    sim.enqueue(place('packer', 7, 5));    // named by nothing -> free
    run(sim, 1);
    expect(at(sim, 5, 5)).toBeUndefined();
    expect(at(sim, 7, 5)).toBeDefined();
  });

  it('drops setRecipe and setRecipeAt for a locked recipe, silently', () => {
    const sim = newSim();
    sim.enqueue(place('other', 5, 5));
    run(sim, 1);
    const id = at(sim, 5, 5)!.id;
    expect(at(sim, 5, 5)!.recipe).toBe('plain'); // defaults to the first UNLOCKED recipe

    sim.enqueue({ kind: 'setRecipe', entity: id, recipe: 'fancy' }); // locked
    run(sim, 1);
    expect(at(sim, 5, 5)!.recipe).toBe('plain');

    sim.enqueue({ kind: 'setRecipeAt', pos: { x: 5, y: 5 }, recipe: 'fancy' }); // locked
    run(sim, 1);
    expect(at(sim, 5, 5)!.recipe).toBe('plain');
  });

  it('gates nothing when the data has no tech at all', () => {
    const sim = newSim({ ...FIXTURE, tech: [] });
    sim.enqueue(place('goldworks', 5, 5));
    run(sim, 1);
    expect(at(sim, 5, 5)).toBeDefined(); // absence from the tree means free, not forbidden
  });
});

describe('research', () => {
  it('runs the whole path: pick a target, feed packs, unlock, build what was refused', () => {
    const sim = newSim();
    expect(sim.snapshot().research).toEqual({ active: null, progress: {}, unlocked: [] });

    // Refused up front.
    sim.enqueue(place('goldworks', 5, 5));
    run(sim, 1);
    expect(at(sim, 5, 5)).toBeUndefined();

    packLine(sim);
    sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
    run(sim, 1);
    expect(sim.snapshot().research!.active).toBe('metallurgy');

    const evs: SimEvent[] = [];
    let done = -1;
    for (let i = 0; i < 800 && done < 0; i++) {
      run(sim, 1, evs);
      const e = evs.find((x) => x.kind === 'researched');
      if (e) done = (e as { tick: number }).tick;
    }
    expect(done).toBeGreaterThan(0);
    expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1);
    expect(evs.find((e) => e.kind === 'researched')).toMatchObject({ tech: 'metallurgy' });

    const r = sim.snapshot().research!;
    expect(r.unlocked).toEqual(['metallurgy']);
    expect(r.active).toBeNull();  // completing clears the target rather than guessing a next
    expect(r.progress).toEqual({});

    // The same command that was silently dropped now works.
    sim.enqueue(place('goldworks', 5, 5));
    run(sim, 1);
    expect(at(sim, 5, 5)).toBeDefined();
    expect(at(sim, 5, 5)!.recipe).toBe('smelt-gold'); // its recipe unlocked with it
  });

  it('tracks progress toward the active tech and stops taking packs it no longer needs', () => {
    const sim = newSim();
    packLine(sim);
    sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); // costs 2 packs
    const evs: SimEvent[] = [];
    for (let i = 0; i < 400; i++) {
      run(sim, 1, evs);
      if (evs.some((e) => e.kind === 'researched')) break;
      const p = sim.snapshot().research!.progress['pack'] ?? 0;
      expect(p).toBeLessThanOrEqual(2); // never banks past the cost
    }
    expect(sim.snapshot().research!.unlocked).toEqual(['flourish']);
    // With nothing being researched, the lab stops accepting deliveries entirely.
    run(sim, 200);
    expect(at(sim, 4, 0)!.inputBuf).toEqual({});
  });

  it('stacks: two labs feed one total', () => {
    const sim = newSim();
    sim.enqueue(place('mine', 0, 0));
    sim.enqueue(place('belt', 1, 0, E));
    sim.enqueue(place('packer', 2, 0));
    sim.enqueue(place('belt', 3, 0, E));
    sim.enqueue(place('lab', 4, 0));      // first lab on the line
    sim.enqueue(place('belt', 4, 1, N));  // a second lab fed from the other side
    sim.enqueue(place('lab', 4, 2));
    sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });

    const evs: SimEvent[] = [];
    run(sim, 800, evs);
    expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1);
    expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
  });

  it('abandons progress when the target changes, and refuses nonsense targets', () => {
    const sim = newSim();
    packLine(sim);
    sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); // 3 packs
    // Catch it mid-research: metallurgy completes on the third pack, and completing would
    // zero progress for a different reason than the one under test.
    let banked = 0;
    for (let i = 0; i < 400 && banked === 0; i++) {
      run(sim, 1);
      banked = sim.snapshot().research!.progress['pack'] ?? 0;
    }
    expect(banked).toBeGreaterThan(0);

    sim.enqueue({ kind: 'setResearch', tech: 'flourish' });
    run(sim, 1);
    expect(sim.snapshot().research!.progress).toEqual({}); // packs are the cost of indecision

    sim.enqueue({ kind: 'setResearch', tech: 'no-such-tech' });
    run(sim, 1);
    expect(sim.snapshot().research!.active).toBe('flourish'); // unchanged

    sim.enqueue({ kind: 'setResearch', tech: null });
    run(sim, 1);
    expect(sim.snapshot().research!.active).toBeNull();
  });

  it('survives save/load, and a restored save can still build what it unlocked', () => {
    const a = newSim();
    packLine(a);
    a.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
    const evs: SimEvent[] = [];
    for (let i = 0; i < 800; i++) {
      run(a, 1, evs);
      if (evs.some((e) => e.kind === 'researched')) break;
    }
    expect(a.snapshot().research!.unlocked).toEqual(['metallurgy']);

    const b = newSim();
    b.load(a.save());
    expect(b.snapshot().research).toEqual(a.snapshot().research);

    // The future still matches: research state is part of the world, not decoration.
    run(a, 500);
    run(b, 500);
    expect(b.snapshot()).toEqual(a.snapshot());

    // And the unlock survived as a RULE, not just as a list — a save that restored the
    // text but not the gate would silently re-lock the player's own factory. Placed in
    // both so the sims stay in step.
    for (const s of [a, b]) {
      s.enqueue(place('goldworks', 9, 9));
      run(s, 1);
      expect(at(s, 9, 9)).toBeDefined();
    }
    expect(b.snapshot()).toEqual(a.snapshot());
  });
});

// Derived, never hard-coded: which machines DATA gates is theirs to change, and it moved
// twice while this round was being written. The rule is mine to pin down; the list isn't.
const gatedByReal = new Set(REAL.tech.flatMap((t) => t.unlocks));
const aGatedMachine = REAL.machines.find((m) => gatedByReal.has(m.id))!;
const aFreeMachine = REAL.machines.find(
  (m) => !gatedByReal.has(m.id) && m.kind === 'crafter' && m.footprint.x <= 2,
)!;

describe('gating against the real tech tree', () => {
  it('locks the machines DATA gates and frees the ones it does not', () => {
    const sim = newSim(REAL, 7);
    sim.enqueue(place(aGatedMachine.id, 0, 0));
    sim.enqueue(place(aFreeMachine.id, 8, 0));
    run(sim, 1);
    expect(at(sim, 0, 0), `${aGatedMachine.id} is gated and must not place`).toBeUndefined();
    expect(at(sim, 8, 0), `${aFreeMachine.id} is ungated and must place`).toBeDefined();
  });

  it('opens the real gate when the real tech completes', () => {
    const unlocker = REAL.tech.find((t) => t.unlocks.includes(aGatedMachine.id))!;
    const sim = newSim(REAL, 7);
    grantResearch(sim, [unlocker.id]);
    sim.enqueue(place(aGatedMachine.id, 0, 0));
    run(sim, 1);
    expect(at(sim, 0, 0)).toBeDefined();
    expect(sim.snapshot().research!.unlocked).toEqual([unlocker.id]);
  });
});

describe('research takes time', () => {
  it('a pack is an investment, not a purchase — it costs RESEARCH_TICKS_PER_PACK', () => {
    const sim = newSim();
    packLine(sim);
    sim.enqueue({ kind: 'setResearch', tech: 'flourish' }); // 2 packs

    // Wait for the first pack to physically reach the lab, then time the conversion.
    let arrived = -1;
    for (let i = 0; i < 600 && arrived < 0; i++) {
      run(sim, 1);
      if ((at(sim, 4, 0)!.inputBuf['pack'] ?? 0) > 0) arrived = sim.snapshot().tick;
    }
    expect(arrived).toBeGreaterThan(0);

    let credited = -1;
    for (let i = 0; i < RESEARCH_TICKS_PER_PACK * 3 && credited < 0; i++) {
      run(sim, 1);
      if ((sim.snapshot().research!.progress['pack'] ?? 0) > 0) credited = sim.snapshot().tick;
    }
    expect(credited).toBeGreaterThan(0);
    // The lab was busy for the full duration, not instantaneous. (The tick the pack lands
    // is also the tick the lab starts on it, hence the -1.)
    expect(credited - arrived).toBeGreaterThanOrEqual(RESEARCH_TICKS_PER_PACK - 1);
    expect(credited - arrived).toBeLessThanOrEqual(RESEARCH_TICKS_PER_PACK + 1);
  });

  it('shows its work: a lab mid-pack reports progress, and clears it when idle', () => {
    const sim = newSim();
    packLine(sim);
    sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });

    let seenWorking = false;
    for (let i = 0; i < 1200 && !seenWorking; i++) {
      run(sim, 1);
      const p = at(sim, 4, 0)!.progress;
      if (p > 0 && p < 1) seenWorking = true;
    }
    expect(seenWorking, 'the lab never showed partial progress').toBe(true);

    // Once there's nothing to research, the lab stops showing a half-done pack.
    sim.enqueue({ kind: 'setResearch', tech: null });
    run(sim, 2);
    expect(at(sim, 4, 0)!.progress).toBe(0);
  });

  it('two labs really do halve the wait', () => {
    // Three mines so pack SUPPLY isn't the bottleneck — otherwise this measures the miner,
    // not the labs, and a second lab would change nothing.
    const finishTick = (labs: number): number => {
      const sim = newSim();
      sim.enqueue(place('mine', 0, 0));
      sim.enqueue(place('belt', 1, 0, E));
      sim.enqueue(place('mine', 0, 2));
      sim.enqueue(place('belt', 1, 2, E));
      sim.enqueue(place('belt', 2, 2, N));
      sim.enqueue(place('belt', 2, 1, N));
      sim.enqueue(place('mine', 0, -2));
      sim.enqueue(place('belt', 1, -2, E));
      sim.enqueue(place('belt', 2, -2, S));
      sim.enqueue(place('belt', 2, -1, S));
      sim.enqueue(place('packer', 2, 0));
      sim.enqueue(place('belt', 3, 0, E));
      sim.enqueue(place('split', 4, 0));
      sim.enqueue(place('belt', 5, 0, E));
      sim.enqueue(place('lab', 6, 0));
      if (labs > 1) {
        sim.enqueue(place('belt', 4, 1, S)); // the splitter's other face feeds lab two
        sim.enqueue(place('lab', 4, 2));
      }
      sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' }); // 3 packs
      for (let i = 0; i < 4000; i++) {
        sim.tick();
        for (const e of sim.drainEvents()) if (e.kind === 'researched') return e.tick;
      }
      return -1;
    };
    const one = finishTick(1);
    const two = finishTick(2);
    expect(one).toBeGreaterThan(0);
    expect(two).toBeGreaterThan(0);
    expect(two, 'a second lab should finish the tech sooner').toBeLessThan(one);
  });

  it('abandoning a target forfeits the half-processed pack in the lab', () => {
    const sim = newSim();
    packLine(sim);
    sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
    let working = false;
    for (let i = 0; i < 1200 && !working; i++) {
      run(sim, 1);
      working = at(sim, 4, 0)!.progress > 0;
    }
    expect(working).toBe(true);
    run(sim, 20); // let it get meaningfully into the pack before we yank the target

    const banked = at(sim, 4, 0)!.progress;
    expect(banked).toBeGreaterThan(2 / RESEARCH_TICKS_PER_PACK); // genuinely part-done

    sim.enqueue({ kind: 'setResearch', tech: 'flourish' });
    run(sim, 1);
    // Back to scratch: at most the single tick it has already spent on the new target.
    expect(at(sim, 4, 0)!.progress).toBeLessThanOrEqual(1 / RESEARCH_TICKS_PER_PACK);
    expect(at(sim, 4, 0)!.progress).toBeLessThan(banked);
  });
});

describe('grantResearch', () => {
  it('unlocks instantly, emits researched, and lets the build through', () => {
    const sim = newSim();
    sim.enqueue(place('goldworks', 5, 5));
    run(sim, 1);
    expect(at(sim, 5, 5)).toBeUndefined(); // refused while locked

    const evs: SimEvent[] = [];
    sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] });
    run(sim, 1, evs);
    expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
    expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1);

    sim.enqueue(place('goldworks', 5, 5));
    run(sim, 1);
    expect(at(sim, 5, 5)).toBeDefined();
  });

  it('is idempotent and ignores nonsense', () => {
    const sim = newSim();
    sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy', 'metallurgy', 'no-such-tech'] });
    const evs: SimEvent[] = [];
    run(sim, 1, evs);
    expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
    expect(evs.filter((e) => e.kind === 'researched')).toHaveLength(1);

    sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] }); // again
    const evs2: SimEvent[] = [];
    run(sim, 1, evs2);
    expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
    expect(evs2.filter((e) => e.kind === 'researched')).toHaveLength(0); // nothing changed
  });

  it('completes the active target out from under the labs', () => {
    const sim = newSim();
    packLine(sim);
    sim.enqueue({ kind: 'setResearch', tech: 'metallurgy' });
    run(sim, 120); // still mid-research: 3 packs at 60 ticks each can't be done yet
    expect(sim.snapshot().research!.active).toBe('metallurgy');

    sim.enqueue({ kind: 'grantResearch', tech: ['metallurgy'] });
    run(sim, 1);
    expect(sim.snapshot().research!.active).toBeNull();
    expect(sim.snapshot().research!.progress).toEqual({});
    expect(sim.snapshot().research!.unlocked).toEqual(['metallurgy']);
  });

  it('survives save/load like any other research', () => {
    const a = newSim();
    a.enqueue({ kind: 'grantResearch', tech: ['metallurgy', 'flourish'] });
    run(a, 1);
    const b = newSim();
    b.load(a.save());
    expect(b.snapshot().research!.unlocked).toEqual(['metallurgy', 'flourish']);
    b.enqueue(place('goldworks', 9, 9));
    run(b, 1);
    expect(at(b, 9, 9)).toBeDefined();
  });
});
