/**
 * M2 mechanics: splitters, buffer tanks, heat, the commission queue.
 *
 * These run on a synthetic fixture rather than data/*.json, deliberately. Two reasons:
 * the real data carries none of the v2 fields yet (no heatPerTick, no bufferCap, no
 * recipe heat, no repeat — see NOTES round 2), and mechanics are LANE-SIM's to pin down
 * while the numbers are LANE-DATA's to move. Round 1 taught that the hard way.
 */
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { HEAT_COOL_DEFAULT, HEAT_COOL_MAX } from './constants';
import type {
  Command, Dir, EntityState, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent,
} from '../contracts';

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 FIXTURE: GameData = {
  items: ['rock', 'gravel', 'sand'].map(item),
  machines: [
    mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
    mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }),
    mach({ id: 'split', kind: 'splitter' }),
    mach({ id: 'ship', kind: 'shipper' }),
    mach({ id: 'gen', kind: 'power', powerGen: 10 }),
    mach({ id: 'hotgen', kind: 'power', powerGen: 10, heatPerTick: 0.02 }),
    // v4 spatial cooling: radiates its coolPerTick to anything within 2 tiles.
    mach({ id: 'cooler', kind: 'power', powerGen: 0, coolPerTick: 0.01, coolRadius: 2 }),
    mach({ id: 'bigcooler', kind: 'power', powerGen: 0, coolPerTick: 0.04, coolRadius: 1 }),
    mach({ id: 'tank', kind: 'buffer', bufferCap: 100 }),
    mach({ id: 'load', kind: 'crafter', powerDraw: 20 }),
    mach({ id: 'grinder', kind: 'crafter', recipes: ['grind'] }),
    mach({ id: 'sifter', kind: 'crafter', recipes: ['sift'] }),
    mach({ id: 'oven', kind: 'crafter', recipes: ['bake'] }),
  ],
  recipes: [
    rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 30 }),
    rec({ id: 'grind', machine: 'grinder', inputs: { rock: 1 }, outputs: { gravel: 1 } }),
    rec({ id: 'sift', machine: 'sifter', inputs: { rock: 1 }, outputs: { sand: 1 } }),
    rec({ id: 'bake', machine: 'oven', inputs: { rock: 1 }, outputs: { gravel: 1 }, ticks: 5, heat: 0.3 }),
  ],
  tech: [],
  commissions: [
    { id: 'c1', flavor: 'two rocks', wants: { rock: 2 }, rewardBandwidth: 1 },
    { id: 'c2', flavor: 'three rocks, forever', wants: { rock: 3 }, rewardBandwidth: 1, repeat: true },
  ],
};

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

describe('splitters', () => {
  it('round-robins across both outputs', () => {
    const sim = newSim();
    sim.enqueue(place('mine', 0, 0));
    sim.enqueue(place('belt', 1, 0, E));
    sim.enqueue(place('split', 2, 0));
    sim.enqueue(place('belt', 3, 0, E));   // branch A -> gravel
    sim.enqueue(place('grinder', 4, 0));
    sim.enqueue(place('belt', 5, 0, E));
    sim.enqueue(place('ship', 6, 0));
    sim.enqueue(place('belt', 2, 1, S));   // branch B -> sand
    sim.enqueue(place('sifter', 2, 2));
    sim.enqueue(place('belt', 2, 3, S));
    sim.enqueue(place('ship', 2, 4));

    run(sim, 4000);
    const { gravel = 0, sand = 0 } = sim.snapshot().shippedTotal;
    expect(gravel).toBeGreaterThan(10);
    expect(sand).toBeGreaterThan(10);
    expect(Math.abs(gravel - sand)).toBeLessThanOrEqual(1); // strict alternation
  });

  it('skips a blocked output instead of waiting on it', () => {
    const sim = newSim();
    sim.enqueue(place('mine', 0, 0));
    sim.enqueue(place('belt', 1, 0, E));
    sim.enqueue(place('split', 2, 0));
    sim.enqueue(place('belt', 3, 0, E));   // branch A -> uplink
    sim.enqueue(place('ship', 4, 0));
    sim.enqueue(place('belt', 2, 1, S));   // branch B -> dead end, fills and wedges
    sim.enqueue(place('belt', 2, 2, S));

    run(sim, 600);
    const early = sim.snapshot().shippedTotal['rock'] ?? 0;
    run(sim, 600);
    const late = sim.snapshot().shippedTotal['rock'] ?? 0;

    expect(early).toBeGreaterThan(0);
    expect(late).toBeGreaterThan(early);          // the good lane keeps flowing
    expect(at(sim, 0, 0)!.jammed).not.toBe('output full'); // the miner never wedges
    const stuck = sim.snapshot().beltItems.filter((i) => i.entity === at(sim, 2, 1)!.id
      || i.entity === at(sim, 2, 2)!.id);
    expect(stuck).toHaveLength(4);               // dead branch holds 2 belts x 2 items
  });

  it('is traced through when ranking a lane — best reachable outcome wins', () => {
    // The miner's belt only reaches a consumer *through* the splitter. If the trace
    // stopped at the splitter the lane would rank 0 and this would still pass, so the
    // dead-end control below is what actually proves it.
    const sim = newSim();
    sim.enqueue(place('mine', 0, 0));
    sim.enqueue(place('belt', 1, 0, E));
    sim.enqueue(place('split', 2, 0));
    sim.enqueue(place('belt', 2, 1, S));
    sim.enqueue(place('grinder', 2, 2));   // rock consumer, reachable only via the splitter
    sim.enqueue(place('belt', 2, 3, S));
    sim.enqueue(place('ship', 2, 4));
    // A rival lane straight to an uplink: rank 1, must lose to the rank-2 lane above.
    sim.enqueue(place('belt', 0, 1, S));
    sim.enqueue(place('ship', 0, 2));

    run(sim, 2000);
    const snap = sim.snapshot();
    expect(snap.shippedTotal['gravel'] ?? 0).toBeGreaterThan(0); // went the consumer's way
    expect(snap.shippedTotal['rock'] ?? 0).toBe(0);              // never took the uplink
  });
});

describe('buffer tanks', () => {
  // v3 units: gen/draw are per SECOND, stored is bandwidth-SECONDS. A 30/s surplus adds
  // 30 stored per second — i.e. 1 per tick — not 30 per tick.
  it('charges on surplus, covers a deficit, and only browns out once dry', () => {
    const sim = newSim();
    sim.enqueue(place('gen', 0, 0));
    sim.enqueue(place('gen', 1, 0));
    sim.enqueue(place('gen', 2, 0));  // gen 30/s
    sim.enqueue(place('tank', 3, 0)); // cap 100 bandwidth-seconds

    run(sim, 1);
    expect(sim.snapshot().bandwidth.stored).toBe(1); // 30/s surplus over 1/30 s = 1
    run(sim, 99);
    expect(sim.snapshot().bandwidth.stored).toBe(100); // full after 100 ticks, then clamps
    run(sim, 10);
    expect(sim.snapshot().bandwidth.stored).toBe(100);
    expect(sim.snapshot().bandwidth.brownout).toBe(false);

    // Draw 60 against gen 30: a 30/s deficit, which is 1 stored per tick. 100 stored is
    // therefore exactly 100 ticks of cover.
    sim.enqueue(place('load', 0, 1));
    sim.enqueue(place('load', 1, 1));
    sim.enqueue(place('load', 2, 1));
    const evs: SimEvent[] = [];
    run(sim, 1, evs);
    expect(sim.snapshot().bandwidth.draw).toBe(60);
    expect(sim.snapshot().bandwidth.stored).toBe(99);
    expect(sim.snapshot().bandwidth.brownout).toBe(false); // the tank is carrying it

    run(sim, 99, evs);
    expect(sim.snapshot().bandwidth.stored).toBe(0);
    expect(sim.snapshot().bandwidth.brownout).toBe(false); // that last tick was still covered
    expect(evs.filter((e) => e.kind === 'brownout')).toHaveLength(0);

    run(sim, 1, evs);
    expect(sim.snapshot().bandwidth.brownout).toBe(true); // dry -> the lights go
    expect(evs.filter((e) => e.kind === 'brownout' && e.on)).toHaveLength(1);
    expect(sim.snapshot().bandwidth.gen).toBe(30); // still reported per second
  });

  it('browns out immediately with no tank at all', () => {
    const sim = newSim();
    sim.enqueue(place('gen', 0, 0));  // 10
    sim.enqueue(place('load', 0, 1)); // 20
    run(sim, 2);
    expect(sim.snapshot().bandwidth.brownout).toBe(true);
    expect(sim.snapshot().bandwidth.stored).toBe(0);
  });
});

describe('heat', () => {
  it('throttles a hot generator, scrams it at 1.0, and restarts it below 0.5', () => {
    const sim = newSim();
    sim.enqueue(place('hotgen', 0, 0));
    const evs: SimEvent[] = [];

    // Climb: +0.02/tick of work against -0.004/tick of ambient loss.
    let throttledGen = -1;
    for (let i = 0; i < 200; i++) {
      run(sim, 1, evs);
      const h = at(sim, 0, 0)!.heat;
      if (h > 0.7 && h < 1) throttledGen = sim.snapshot().bandwidth.gen;
      if (evs.some((e) => e.kind === 'scram' && e.on)) break;
    }
    // Between 0.7 and 1.0 it visibly slows without stopping: the lore's fan-scream band.
    expect(throttledGen).toBeGreaterThan(5);
    expect(throttledGen).toBeLessThan(10);

    const scramOn = evs.filter((e) => e.kind === 'scram' && e.on);
    expect(scramOn).toHaveLength(1);
    expect(at(sim, 0, 0)!.heat).toBe(1);

    run(sim, 1);
    expect(sim.snapshot().bandwidth.gen).toBe(0); // scrammed: contributes nothing

    // Cool: 1.0 -> below 0.5 at 0.004/tick is ~125 ticks. It restarts the moment it gets
    // there and immediately starts reheating, so catch the heat on the restart tick.
    const evs2: SimEvent[] = [];
    let heatAtRestart = -1;
    let restartTick = -1;
    for (let i = 0; i < 200; i++) {
      run(sim, 1, evs2);
      if (evs2.some((e) => e.kind === 'scram' && !e.on)) {
        heatAtRestart = at(sim, 0, 0)!.heat;
        restartTick = i + 1;
        break;
      }
    }
    expect(evs2.filter((e) => e.kind === 'scram' && !e.on)).toHaveLength(1);
    expect(heatAtRestart).toBeLessThan(0.5);
    expect(restartTick).toBeGreaterThan(100); // it genuinely had to cool down
    run(sim, 1);
    expect(sim.snapshot().bandwidth.gen).toBe(10); // back on the bus
  });

  it('duty-cycles: a generator hot enough to scram keeps restarting and scramming', () => {
    const sim = newSim();
    sim.enqueue(place('hotgen', 0, 0));
    const evs: SimEvent[] = [];
    run(sim, 1200, evs);
    const scrams = evs.filter((e) => e.kind === 'scram');
    expect(scrams.filter((e) => (e as { on: boolean }).on).length).toBeGreaterThan(2);
    expect(scrams.filter((e) => !(e as { on: boolean }).on).length).toBeGreaterThan(2);
    // Edges alternate — never two ons in a row.
    const ons = scrams.map((e) => (e as { on: boolean }).on);
    for (let i = 1; i < ons.length; i++) expect(ons[i]).not.toBe(ons[i - 1]);
  });

  it('adds recipe heat per craft and sheds it when idle', () => {
    const sim = newSim();
    sim.enqueue(place('mine', 0, 0));
    sim.enqueue(place('belt', 1, 0, E));
    sim.enqueue(place('oven', 2, 0));

    const evs: SimEvent[] = [];
    let heatAfterCraft = -1;
    for (let i = 0; i < 400; i++) {
      run(sim, 1, evs);
      if (evs.some((e) => e.kind === 'crafted' && e.recipe === 'bake')) {
        heatAfterCraft = at(sim, 2, 0)!.heat;
        break;
      }
    }
    // 0.3 from the bake, less one tick of ambient loss.
    expect(heatAfterCraft).toBeCloseTo(0.3 - 0.004, 5);

    const before = at(sim, 2, 0)!.heat;
    sim.enqueue({ kind: 'remove', pos: { x: 0, y: 0 } }); // cut the feed, let it cool
    run(sim, 20);
    expect(at(sim, 2, 0)!.heat).toBeLessThan(before);
  });

  /** Ticks until the generator at (x,y) first scrams, or -1 if it never does. */
  const ticksToScram = (sim: Sim, limit = 1500): number => {
    for (let i = 0; i < limit; i++) {
      const evs: SimEvent[] = [];
      run(sim, 1, evs);
      if (evs.some((e) => e.kind === 'scram' && e.on)) return i + 1;
    }
    return -1;
  };

  describe('spatial cooling', () => {
    it('a cooler in range extends a hot generator’s run before it scrams', () => {
      const bare = newSim();
      bare.enqueue(place('hotgen', 0, 0));
      const alone = ticksToScram(bare);
      expect(alone).toBeGreaterThan(0); // 0.02 gross - 0.004 default cooling

      const cooled = newSim();
      cooled.enqueue(place('hotgen', 0, 0));
      cooled.enqueue(place('cooler', 2, 0)); // 2 tiles away: inside the radius
      const withAura = ticksToScram(cooled);
      expect(withAura).toBeGreaterThan(0);
      // 0.02 - (0.004 + 0.01) = 0.006/tick instead of 0.016: roughly 2.5x the run.
      expect(withAura).toBeGreaterThan(alone * 2);
    });

    it('the aura is bounded — a cooler out of range does nothing', () => {
      const bare = newSim();
      bare.enqueue(place('hotgen', 0, 0));
      const alone = ticksToScram(bare);

      const far = newSim();
      far.enqueue(place('hotgen', 0, 0));
      far.enqueue(place('cooler', 5, 0)); // radius is 2; this is 5 away
      expect(ticksToScram(far)).toBe(alone);
      expect(at(far, 0, 0)!.heat).toBe(at(bare, 0, 0)!.heat);
    });

    it('enough cooling in range stops it overheating at all', () => {
      const sim = newSim();
      sim.enqueue(place('hotgen', 0, 0));
      sim.enqueue(place('bigcooler', 1, 0)); // 0.004 + 0.04 = 0.044 > 0.02 gross
      const evs: SimEvent[] = [];
      run(sim, 2000, evs);
      expect(evs.filter((e) => e.kind === 'scram')).toHaveLength(0);
      expect(at(sim, 0, 0)!.heat).toBe(0);
      expect(sim.snapshot().bandwidth.gen).toBe(10); // never throttled, never dropped
    });

    it('auras stack by sum, and the total is capped however many you pile on', () => {
      // The oven takes 0.3 heat per bake and has no heatPerTick, so between bakes its heat
      // moves by exactly -cooling. That makes the shed rate directly measurable.
      const shedRate = (coolers: Array<[number, number]>): number => {
        const sim = newSim();
        sim.enqueue(place('mine', 0, 0));
        sim.enqueue(place('belt', 1, 0, E));
        sim.enqueue(place('oven', 2, 0));
        for (const [x, y] of coolers) sim.enqueue(place('bigcooler', x, y));
        for (let i = 0; i < 400; i++) {
          run(sim, 1);
          if (at(sim, 2, 0)!.heat > 0.1) break; // a bake just landed
        }
        const before = at(sim, 2, 0)!.heat;
        run(sim, 1);
        return before - at(sim, 2, 0)!.heat;
      };

      expect(shedRate([])).toBeCloseTo(HEAT_COOL_DEFAULT, 6);
      // One in range: own default + the aura, summed.
      expect(shedRate([[3, 0]])).toBeCloseTo(HEAT_COOL_DEFAULT + 0.04, 6);
      // Four in range would be 0.004 + 0.16 = 0.164 — the cap holds it at HEAT_COOL_MAX.
      const piled = shedRate([[3, 0], [2, 1], [3, 1], [2, -1]]);
      expect(piled).toBeCloseTo(HEAT_COOL_MAX, 6);
      expect(HEAT_COOL_MAX).toBeLessThan(HEAT_COOL_DEFAULT + 4 * 0.04); // the cap really bites
    });

    it('removing a cooler puts the heat back', () => {
      const sim = newSim();
      sim.enqueue(place('hotgen', 0, 0));
      sim.enqueue(place('bigcooler', 1, 0));
      run(sim, 300);
      expect(at(sim, 0, 0)!.heat).toBe(0); // held cold by the aura

      sim.enqueue({ kind: 'remove', pos: { x: 1, y: 0 } });
      run(sim, 200);
      expect(at(sim, 0, 0)!.heat).toBeGreaterThan(0); // aura gone, it heats again
    });
  });

  it('leaves cold machines alone', () => {
    const sim = newSim();
    sim.enqueue(place('gen', 0, 0)); // no heatPerTick
    run(sim, 200);
    expect(at(sim, 0, 0)!.heat).toBe(0);
    expect(sim.snapshot().bandwidth.gen).toBe(10);
  });
});

describe('commission queue', () => {
  it('advances in data order and re-queues standing orders', () => {
    const sim = newSim();
    sim.enqueue(place('mine', 0, 0));
    sim.enqueue(place('belt', 1, 0, E));
    sim.enqueue(place('ship', 2, 0));
    run(sim, 1);
    expect(sim.snapshot().activeCommission).toBe('c1');

    const evs: SimEvent[] = [];
    run(sim, 400, evs);
    const done = evs
      .filter((e) => e.kind === 'commissionDone')
      .map((e) => (e as { commission: string }).commission);

    expect(done.slice(0, 2)).toEqual(['c1', 'c2']);        // data order
    expect(done.filter((c) => c === 'c1')).toHaveLength(1); // no repeat: gone for good
    expect(done.filter((c) => c === 'c2').length).toBeGreaterThan(1); // standing order returns
    expect(sim.snapshot().activeCommission).toBe('c2');
    expect(sim.snapshot().commissionProgress['rock']).toBeLessThan(3); // counting afresh
  });

  it('banks nothing for orders that are not active', () => {
    const sim = newSim();
    sim.enqueue(place('mine', 0, 0));
    sim.enqueue(place('belt', 1, 0, E));
    sim.enqueue(place('grinder', 2, 0)); // rock -> gravel: nothing any commission wants
    sim.enqueue(place('belt', 3, 0, E));
    sim.enqueue(place('ship', 4, 0));

    run(sim, 600);
    expect(sim.snapshot().shippedTotal['gravel']).toBeGreaterThan(0);
    expect(sim.snapshot().activeCommission).toBe('c1');
    expect(sim.snapshot().commissionProgress['rock']).toBe(0);
  });
});

describe('starved', () => {
  it('reports an idle recipe with nothing to eat, edge-triggered', () => {
    const sim = newSim();
    sim.enqueue(place('grinder', 5, 5)); // a recipe, no feed
    const evs: SimEvent[] = [];
    run(sim, 100, evs);
    expect(at(sim, 5, 5)!.jammed).toBe('starved');
    expect(evs.filter((e) => e.kind === 'jammed')).toHaveLength(1); // once, not per tick

    sim.enqueue(place('mine', 3, 5));
    sim.enqueue(place('belt', 4, 5, E));
    sim.enqueue(place('belt', 6, 5, E)); // and somewhere to put the gravel
    sim.enqueue(place('ship', 7, 5));

    // The grinder eats a rock a tick and the mine yields one per 30, so it goes back to
    // starved between deliveries — the flag clears on the tick it actually gets fed.
    let cleared = false;
    for (let i = 0; i < 400 && !cleared; i++) {
      run(sim, 1);
      cleared = at(sim, 5, 5)!.jammed === null;
    }
    expect(cleared).toBe(true);
    run(sim, 200); // and it really is producing, not just briefly unflagged
    expect(sim.snapshot().shippedTotal['gravel']).toBeGreaterThan(0);
  });

  it('never calls an extractor starved — it has nothing to be starved of', () => {
    const sim = newSim();
    sim.enqueue(place('mine', 0, 0));
    run(sim, 20);
    expect(at(sim, 0, 0)!.jammed).toBeNull();
  });
});

describe('belt item ids', () => {
  it('are unique and survive belt transfers and splitters', () => {
    const sim = newSim();
    sim.enqueue(place('mine', 0, 0));
    sim.enqueue(place('belt', 1, 0, E));
    sim.enqueue(place('split', 2, 0));
    sim.enqueue(place('belt', 3, 0, E));
    sim.enqueue(place('belt', 4, 0, E));
    sim.enqueue(place('belt', 2, 1, S));
    sim.enqueue(place('belt', 2, 2, S));

    /** entity each id has been seen on, to prove an id crosses tiles rather than respawning */
    const seenOn = new Map<number, Set<number>>();
    for (let i = 0; i < 400; i++) {
      run(sim, 1);
      const ids = new Set<number>();
      for (const it of sim.snapshot().beltItems) {
        expect(it.id).toBeDefined();
        expect(ids.has(it.id!)).toBe(false); // unique within a snapshot
        ids.add(it.id!);
        const where = seenOn.get(it.id!) ?? new Set<number>();
        where.add(it.entity);
        seenOn.set(it.id!, where);
      }
    }
    // At least one item kept its id across more than one belt (and through the splitter).
    const travelled = [...seenOn.values()].filter((s) => s.size > 1);
    expect(travelled.length).toBeGreaterThan(0);
    expect(Math.max(...[...seenOn.values()].map((s) => s.size))).toBeGreaterThanOrEqual(3);
  });
});

describe('determinism holds with the new mechanics', () => {
  it('two fixture sims with splitters, tanks and heat agree after 1,000 ticks', () => {
    const build = (): Sim => {
      const sim = newSim(99);
      sim.enqueue(place('hotgen', 0, 5));
      sim.enqueue(place('gen', 1, 5));
      sim.enqueue(place('tank', 2, 5));
      sim.enqueue(place('mine', 0, 0));
      sim.enqueue(place('belt', 1, 0, E));
      sim.enqueue(place('split', 2, 0));
      sim.enqueue(place('belt', 3, 0, E));
      sim.enqueue(place('oven', 4, 0));
      sim.enqueue(place('belt', 5, 0, E));
      sim.enqueue(place('ship', 6, 0));
      sim.enqueue(place('belt', 2, 1, S));
      sim.enqueue(place('sifter', 2, 2));
      sim.enqueue(place('belt', 2, 3, S));
      sim.enqueue(place('ship', 2, 4));
      run(sim, 1000);
      return sim;
    };
    expect(build().snapshot()).toEqual(build().snapshot());
  });
});
