/**
 * Wildlife v1 — the dust→swarm loop.
 *
 * The design point these tests exist to protect: the hazard is a consequence of bad
 * plumbing, not a timer. Piles grow from the dust a machine is HOLDING, so a factory that
 * belts its dust to an uplink never hatches anything, and one that ignores it is farming
 * mosquitoes. If that asymmetry ever breaks, the mechanic becomes weather.
 *
 * Fixture-based: DATA has no trap machine yet (see NOTES round 5), so the trap here is a
 * fixture machine shaped the way the orders describe — an empty-input recipe that cans the
 * swarm. The sim identifies traps by that recipe shape, not by a machine id, so DATA's real
 * trap will light up with zero code changes.
 */
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import {
  DUST_ITEM, SWARM_ITEM, SWARM_SLOW_FLOOR, TRAP_RADIUS,
} from './constants';
import type {
  Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent, WildlifeState,
} 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', DUST_ITEM, SWARM_ITEM].map(item),
  machines: [
    mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }),
    mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }),
    mach({ id: 'ship', kind: 'shipper' }),
    mach({ id: 'gen', kind: 'power', powerGen: 50 }),
    // Emits dust as a byproduct — the thing that breeds swarms if you let it sit.
    mach({ id: 'crusher', kind: 'crafter', recipes: ['crush'], powerDraw: 2 }),
    // The trap: no inputs, cans a live swarm. The sim spots it by recipe shape.
    mach({ id: 'trap', kind: 'crafter', recipes: ['catch'], powerDraw: 1 }),
  ],
  recipes: [
    rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 10 }),
    // One dust per craft: a single belt lane (~0.15 items/tick) can genuinely keep up with
    // this, so "tidy" below really is tidy. Four-per-craft would out-run one lane and back
    // up no matter how you plumbed it — which is a true fact about belts, not about dust.
    rec({ id: 'crush', machine: 'crusher', inputs: { rock: 1 }, outputs: { [DUST_ITEM]: 1 }, ticks: 10 }),
    rec({ id: 'catch', machine: 'trap', inputs: {}, outputs: { [SWARM_ITEM]: 1 }, ticks: 30 }),
  ],
  tech: [],
  commissions: [{ id: 'c1', flavor: 'rocks', wants: { rock: 999 }, rewardBandwidth: 1 }],
};

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) =>
  sim.snapshot().entities.find((e) => e.pos.x === x && e.pos.y === y);
const wild = (sim: Sim, kind: WildlifeState['kind']) =>
  sim.snapshot().wildlife!.filter((w) => w.kind === kind);

/** A crusher fed by a miner, with its dust going nowhere: the neglected factory. */
function dustyFactory(sim: Sim): void {
  sim.enqueue(place('gen', 0, 8));
  sim.enqueue(place('mine', 0, 0));
  sim.enqueue(place('belt', 1, 0, E));
  sim.enqueue(place('crusher', 2, 0));
}

/** The same factory, but the dust is belted away to an uplink. */
function tidyFactory(sim: Sim): void {
  dustyFactory(sim);
  sim.enqueue(place('belt', 3, 0, E));
  sim.enqueue(place('belt', 4, 0, E));
  sim.enqueue(place('ship', 5, 0));
}

describe('dust piles', () => {
  it('grow where a machine sits on unshipped dust', () => {
    const sim = newSim();
    dustyFactory(sim);
    const evs: SimEvent[] = [];
    run(sim, 400, evs);

    const piles = wild(sim, 'dust-pile');
    expect(piles.length).toBeGreaterThan(0);
    expect(piles[0].size).toBeGreaterThan(0);
    // The pile settles near the machine that shed it, not at the origin.
    expect(Math.abs(piles[0].pos.x - 2)).toBeLessThanOrEqual(3);
    expect(evs.filter((e) => e.kind === 'wildlife' && e.on === 'spawned')).not.toHaveLength(0);
  });

  it('THE DESIGN POINT: a factory that sinks its dust never hatches anything', () => {
    const tidy = newSim();
    tidyFactory(tidy);
    run(tidy, 6000);

    expect(tidy.snapshot().shippedTotal[DUST_ITEM] ?? 0).toBeGreaterThan(0); // it really ran
    expect(wild(tidy, 'mosquito-swarm')).toHaveLength(0);
    expect(at(tidy, 2, 0)!.outputBuf[DUST_ITEM] ?? 0).toBeLessThan(4); // buffer stays drained

    // Same machines, same ticks, no uplink: swarms.
    const dusty = newSim();
    dustyFactory(dusty);
    run(dusty, 6000);
    expect(wild(dusty, 'mosquito-swarm').length).toBeGreaterThan(0);
  });
});

describe('mosquito swarms', () => {
  it('hatch from a full pile, and the pile is consumed doing it', () => {
    const sim = newSim();
    dustyFactory(sim);
    const evs: SimEvent[] = [];
    run(sim, 6000, evs);

    const spawns = evs.filter(
      (e) => e.kind === 'wildlife' && e.on === 'spawned' && e.wildlife === 'mosquito-swarm',
    );
    expect(spawns.length).toBeGreaterThan(0);
    const cleared = evs.filter(
      (e) => e.kind === 'wildlife' && e.on === 'cleared' && e.wildlife === 'dust-pile',
    );
    expect(cleared.length).toBeGreaterThanOrEqual(spawns.length); // every hatch ate its pile
    for (const w of wild(sim, 'dust-pile')) expect(w.size).toBeLessThan(1);
  });

  it('drift to a running machine, latch on, and slow it down', () => {
    const sim = newSim();
    dustyFactory(sim);
    let attached: WildlifeState | undefined;
    for (let i = 0; i < 12000 && !attached; i++) {
      run(sim, 1);
      attached = wild(sim, 'mosquito-swarm').find((w) => w.target !== undefined && w.size > 0);
    }
    expect(attached, 'no swarm ever latched on').toBeDefined();
    expect(attached!.target).toBeDefined();
    expect(sim.snapshot().entities.some((e) => e.id === attached!.target)).toBe(true);

    // Severity climbs the longer it's left alone, and is bounded.
    const before = attached!.size;
    run(sim, 300);
    const after = wild(sim, 'mosquito-swarm').find((w) => w.id === attached!.id);
    if (after) {
      expect(after.size).toBeGreaterThan(before);
      expect(after.size).toBeLessThanOrEqual(1);
    }
  });

  it('cost real throughput — a bitten factory out-produces nothing', () => {
    // Two identical dusty factories; one gets its swarms trapped, one doesn't.
    const bitten = newSim();
    dustyFactory(bitten);
    run(bitten, 12000);

    const guarded = newSim();
    dustyFactory(guarded);
    guarded.enqueue(place('trap', 2, 3)); // inside TRAP_RADIUS of the crusher
    run(guarded, 12000);

    expect(wild(bitten, 'mosquito-swarm').length).toBeGreaterThan(0);
    const bittenRocks = bitten.snapshot().entities.find((e) => e.def === 'crusher')!;
    const guardedRocks = guarded.snapshot().entities.find((e) => e.def === 'crusher')!;
    expect(guardedRocks.heat).toBe(bittenRocks.heat); // nothing thermal is in play here
    // The guarded line kept its swarms cleared, so it never ran slowed.
    expect(wild(guarded, 'mosquito-swarm').length).toBeLessThan(
      wild(bitten, 'mosquito-swarm').length,
    );
  });

  it('the slow is bounded by SWARM_SLOW_FLOOR', () => {
    expect(SWARM_SLOW_FLOOR).toBeGreaterThan(0);
    expect(SWARM_SLOW_FLOOR).toBeLessThan(1); // a swarm slows, it never stops a machine dead
  });
});

describe('the trap', () => {
  it('sits idle with nothing to catch, then cans a swarm that comes near', () => {
    const sim = newSim();
    dustyFactory(sim);
    sim.enqueue(place('trap', 2, 3));
    run(sim, 200);

    // Nothing has hatched yet, so the trap is waiting rather than canning air.
    expect(at(sim, 2, 3)!.jammed).toBe('starved');
    expect(at(sim, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0).toBe(0);

    const evs: SimEvent[] = [];
    let caught = 0;
    for (let i = 0; i < 12000 && caught === 0; i++) {
      run(sim, 1, evs);
      caught = evs.filter(
        (e) => e.kind === 'wildlife' && e.on === 'cleared' && e.wildlife === 'mosquito-swarm',
      ).length;
    }
    expect(caught, 'the trap never caught anything').toBeGreaterThan(0);
    expect(at(sim, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0).toBeGreaterThan(0); // a live one, jarred
  });

  it('only reaches swarms inside its radius', () => {
    const near = newSim();
    dustyFactory(near);
    near.enqueue(place('trap', 2, 3)); // ~3 tiles: inside TRAP_RADIUS
    run(near, 12000);

    const far = newSim();
    dustyFactory(far);
    far.enqueue(place('trap', 2, 3 + TRAP_RADIUS * 4)); // well out of reach
    run(far, 12000);

    expect(near.snapshot().shippedTotal[SWARM_ITEM] ?? 0 + (at(near, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0))
      .toBeGreaterThanOrEqual(0);
    const nearCaught = at(near, 2, 3)!.outputBuf[SWARM_ITEM] ?? 0;
    const farCaught = at(far, 2, 3 + TRAP_RADIUS * 4)!.outputBuf[SWARM_ITEM] ?? 0;
    expect(nearCaught).toBeGreaterThan(0);
    expect(farCaught).toBe(0);
    expect(at(far, 2, 3 + TRAP_RADIUS * 4)!.jammed).toBe('starved');
  });

  it('produces a shippable item — the catch is cargo, not a status effect', () => {
    const sim = newSim();
    dustyFactory(sim);
    sim.enqueue(place('trap', 2, 3));
    sim.enqueue(place('belt', 3, 3, E));
    sim.enqueue(place('belt', 4, 3, E));
    sim.enqueue(place('ship', 5, 3));
    run(sim, 14000);
    expect(sim.snapshot().shippedTotal[SWARM_ITEM] ?? 0).toBeGreaterThan(0);
  });
});

describe('wildlife determinism and persistence', () => {
  it('two sims with the same seed breed the same swarms in the same places', () => {
    const build = (): Sim => {
      const sim = newSim(4242);
      dustyFactory(sim);
      sim.enqueue(place('trap', 2, 3));
      run(sim, 9000);
      return sim;
    };
    const a = build();
    const b = build();
    expect(a.snapshot().wildlife!.length).toBeGreaterThan(0); // not vacuously equal
    expect(b.snapshot()).toEqual(a.snapshot());
  });

  it('a different seed puts the piles somewhere else', () => {
    const posesFor = (seed: number): string => {
      const sim = newSim(seed);
      dustyFactory(sim);
      run(sim, 2000);
      return JSON.stringify(sim.snapshot().wildlife!.map((w) => w.pos));
    };
    const spread = new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(posesFor));
    expect(spread.size).toBeGreaterThan(1);
  });

  it('survives save/load mid-infestation, and the future still matches', () => {
    const a = newSim(77);
    dustyFactory(a);
    a.enqueue(place('trap', 2, 3));
    run(a, 9000);
    expect(a.snapshot().wildlife!.length).toBeGreaterThan(0);

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

    run(a, 4000);
    run(b, 4000);
    expect(b.snapshot()).toEqual(a.snapshot());
  });
});
