// @vitest-environment happy-dom
/**
 * LANE-UI — the HUD against the REAL sim, driven by SIM's reference factory.
 *
 * Not a unit test and not a mock in sight: this boots `createSim()` + `createUI()` on the
 * real `data/*.json`, builds `referenceFactory()`, and pumps ticks through the same
 * `update(snapshot, events)` path main.ts uses — then reads the actual DOM.
 *
 * Why this exists: the pressure states the round asks me to confirm (THROTTLING at ~304
 * ticks of generation, SCRAM at ~435, anchor slabs reaching an uplink, a standing order
 * completing) need thousands of *continuous* ticks. The browser pane backgrounds the tab,
 * which pauses requestAnimationFrame, so the live page only advances ~10 ticks per
 * screenshot — hundreds of captures to reach a scram, with an HMR reload from a parallel
 * lane wiping the world every minute or so. Headless, the same states arrive in
 * milliseconds and stay reproducible. See NOTES.
 */
import { describe, expect, it } from 'vitest';
import type { EntityState, GameData, SelectionState, Sim, UIBus, Vec2 } from '../contracts';
import { createSim } from '../sim';
import { RESEARCH_TICKS_PER_PACK } from '../sim/constants';
import { referenceFactory } from '../sim/reference';
import { grantAllResearch, grantResearch } from '../sim/testkit';
import { createUI } from './index';

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;

/**
 * v5 (order 4): research is granted through LANE-SIM's own `testkit`, not by hand-editing
 * a save blob here. My round-4 version duplicated that round-trip and coupled this suite
 * to SIM's save schema — exactly the fragility I flagged then.
 */
const unlockAll = (sim: Sim) => grantAllResearch(sim, DATA);

function harness(opts: { unlocked?: boolean; reference?: boolean } = {}) {
  document.body.innerHTML = '<div id="game"></div><div id="ui"></div>';
  const sim = createSim();
  const ui = createUI();
  const game = document.getElementById('game')!;
  let pick: Vec2 | null = null;
  let state: SelectionState = null;

  // A faithful stand-in for main.ts's v4 bus: it owns the selection, and it is the one
  // that dispatches `place`/`remove` on a world click. The UI must never do that itself.
  const bus: UIBus = {
    dispatch: (c) => sim.enqueue(c),
    selectedBuild: () =>
      state && state.mode === 'build' ? { def: state.def, dir: state.dir } : null,
    pickTile: () => pick,
    selection: () => state,
    setSelection: (s) => { state = s; },
  };

  sim.init(DATA, 1337);
  if (opts.unlocked) unlockAll(sim); // before any tick, so placements aren't dropped
  ui.init(document.getElementById('ui')!, DATA, bus);

  // main.ts's own click glue, copied verbatim in behaviour.
  game.addEventListener('pointerdown', (e) => {
    if ((e as MouseEvent).button !== 0) return;
    const sel = bus.selection!();
    if (!sel || !pick) return;
    if (sel.mode === 'remove') sim.enqueue({ kind: 'remove', pos: pick });
    else if (sel.mode === 'build') sim.enqueue({ kind: 'place', def: sel.def, pos: pick, dir: sel.dir });
  });

  if (opts.reference !== false) for (const c of referenceFactory(DATA)) sim.enqueue(c);

  const step = (n = 1) => {
    for (let i = 0; i < n; i++) {
      sim.tick();
      ui.update(sim.snapshot(), sim.drainEvents());
    }
  };
  /** A real click on the world: main.ts's handler and the UI's both see it. */
  const clickTile = (t: Vec2) => {
    pick = t;
    game.dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true }));
  };
  const find = (defId: string): EntityState | undefined =>
    sim.snapshot().entities.find((e) => e.def === defId);

  return { sim, step, clickTile, find, bus, state: () => state };
}

const $ = (s: string) => document.querySelector(s) as HTMLElement;
const on = (s: string, c = 'is-on') => $(s)?.classList.contains(c);
const toasts = () => Array.from(document.querySelectorAll('.fk-toast')).map((t) => t.textContent);

describe('the HUD against the reference factory', () => {
  it('boots, builds and reports a working factory', () => {
    const h = harness({ unlocked: true });
    h.step(200);
    const snap = h.sim.snapshot();
    expect(snap.entities.length).toBeGreaterThan(20);
    expect(snap.bandwidth.gen).toBeGreaterThan(0);
    expect($('#fk-top').textContent).toContain('GEN');
  });

  it('shows heat climbing, then THROTTLING, then SCRAM on a software decoder', () => {
    const h = harness({ unlocked: true });
    h.step(50);

    const dec = h.find('software-decoder');
    expect(dec, 'reference factory should contain a software decoder').toBeTruthy();
    h.clickTile(dec!.pos); // inspect it the way a player would
    h.step(1);
    expect($('#fk-inspect').classList.contains('is-open')).toBe(true);
    expect($('.fk-ins-name').textContent).toBe('SOFTWARE DECODER');

    // heatPerTick 0.0033 - coolPerTick 0.001 = +0.0023/tick while generating.
    h.step(250); // ~0.69 — hot, not yet throttling
    expect($('.fk-heat').style.display).not.toBe('none');
    expect(on('.fk-heat', 'is-throttling')).toBe(false);

    h.step(100); // past 0.7
    expect(on('.fk-heat', 'is-throttling')).toBe(true);
    expect($('.fk-ins-heat-state').textContent).toBe('THROTTLING');

    h.step(200); // past 1.0 -> SIM latches scrammed
    expect(h.find('software-decoder')!.scrammed).toBe(true);
    expect(on('.fk-heat', 'is-scram')).toBe(true);
    expect($('.fk-ins-heat-state').textContent).toBe('SCRAM — UNIT OFFLINE');
    expect(on('.fk-ins-heat-state', 'is-scram')).toBe(true);
  });

  it('keeps reading SCRAM from the latch while the unit cools back below 1.0', () => {
    // The exact case `heat >= 1` got wrong, and why contracts v3 added the bool.
    const h = harness({ unlocked: true });
    h.step(50);
    const dec = h.find('software-decoder')!;
    h.clickTile(dec.pos);
    h.step(600); // well past scram

    const e = h.find('software-decoder')!;
    expect(e.scrammed).toBe(true);
    expect(e.heat).toBeLessThan(1); // already shedding heat while offline
    expect(on('.fk-heat', 'is-scram')).toBe(true); // ...and still reads SCRAM
    expect(on('.fk-heat', 'is-throttling')).toBe(false);
  });

  it('ships and tickers up', () => {
    const h = harness({ unlocked: true });
    h.step(3000);
    const shipped = h.sim.snapshot().shippedTotal;
    expect(Object.values(shipped).reduce((a, b) => a + b, 0)).toBeGreaterThan(0);
    expect($('#fk-top').textContent).toMatch(/SHIPPED/);
    expect($('#fk-top .fk-chip')).toBeTruthy(); // per-item shipped chips
  });

  it('raises the SANITISING chip on real anchor-slab shipments', () => {
    // The reference factory never ships slabs — it recycles them into the i-only
    // assembler on purpose ("Rather than sell them, feed the i-only assembler").
    // So do what a player would: demolish that assembler and drop an uplink on it.
    const h = harness({ unlocked: true });
    h.step(200);
    expect(on('.fk-san')).toBe(false); // nothing sanitised yet

    // Find it by what it *is*, not where it was — reference.ts moves its layout around.
    const iOnly = h.sim.snapshot().entities.find((e) => e.recipe === 'assemble-gop-i-only');
    expect(iOnly, 'reference factory should recycle slabs through an i-only assembler')
      .toBeTruthy();
    h.bus.dispatch({ kind: 'remove', pos: iOnly!.pos });
    h.bus.dispatch({ kind: 'place', def: 'shipper', pos: iOnly!.pos, dir: 0 });

    let shippedAt = -1;
    for (let t = 0; t < 12000 && shippedAt < 0; t += 100) {
      h.step(100);
      if ((h.sim.snapshot().shippedTotal['anchor-slab'] ?? 0) > 0) shippedAt = t;
    }
    expect(shippedAt, 'slabs should reach the new uplink').toBeGreaterThan(0);
    expect(on('.fk-san')).toBe(true);
    expect($('.fk-san .fk-chip').textContent).toContain('I-FRAME');
  });

  /**
   * DATA's v5 commission queue opens with `raw-payload` — 20 raw MDAT ORE — and the
   * reference factory refines every scrap of its ore rather than shipping it, so the head
   * commission can never complete and blocks all 19 behind it (see NOTES: head-of-line
   * blocking). A player would do the obvious thing: tap raw ore straight to an uplink.
   * `GameData` ships no seam map, so the whole map is minable and this works anywhere.
   */
  function shipRawOre(h: ReturnType<typeof harness>, x = 24, y = 24) {
    // extractor (2x2) -> belt -> uplink (2x2), all facing east. The belt is required:
    // machines only ever push onto belts, never straight into an adjacent machine.
    h.bus.dispatch({ kind: 'place', def: 'seam-extractor', pos: { x, y }, dir: 1 });
    h.bus.dispatch({ kind: 'place', def: 'belt', pos: { x: x + 2, y }, dir: 1 });
    h.bus.dispatch({ kind: 'place', def: 'shipper', pos: { x: x + 3, y }, dir: 1 });
  }

  it('reads NEXT IN TRAY from the real commission queue, and advances it on completion', () => {
    const h = harness({ unlocked: true });
    h.step(10);

    const first = h.sim.snapshot();
    expect(first.commissionQueue, 'SIM publishes the queue').toBeTruthy();
    expect(first.activeCommission).toBe(first.commissionQueue![0]);

    // The peek is the queue's second entry, not a guess from data order.
    const nextId = first.commissionQueue![1];
    expect(on('.fk-fax-next')).toBe(true);
    expect($('.fk-fax-next-name').textContent).toBe(nextId.replace(/-/g, ' ').toUpperCase());

    // Satisfy the head commission for real, and watch the tray move up.
    shipRawOre(h);
    for (let t = 0; t < 12000 && h.sim.snapshot().activeCommission === first.activeCommission; t += 100) {
      h.step(100);
    }
    const after = h.sim.snapshot();
    expect(after.activeCommission, 'the queue should advance once the head is paid')
      .toBe(nextId);
    expect($('.fk-fax-next-name').textContent)
      .toBe(after.commissionQueue![1].replace(/-/g, ' ').toUpperCase());
  });

  it('completes a commission and stamps the fax', () => {
    const h = harness({ unlocked: true });
    shipRawOre(h);
    let done = false;
    for (let t = 0; t < 12000 && !done; t += 50) {
      h.step(50);
      if (toasts().some((x) => x?.includes('FAX SENT'))) done = true;
    }
    expect(done, 'the factory should close at least one commission').toBe(true);
    expect(on('.fk-fax-stamp')).toBe(true);
  });

  it('speaks in the house voice when things go wrong', () => {
    const h = harness({ unlocked: true });
    h.step(400);
    const all = toasts().join(' | ');
    // Something in a cold-starting factory is always starved; whatever it is, it must
    // read as English, not as a sim enum.
    expect(all).not.toContain('output full');
    expect(all).not.toContain('starved');
  });
});

describe('research against the real sim', () => {
  const SOFTWARE_TECH = 'stream-software-decoding'; // gates 'software-decoder'
  const lockBtn = () =>
    Array.from(document.querySelectorAll('.fk-build-btn')).find((b) =>
      b.textContent?.includes('SOFTWARE DECODER'),
    ) as HTMLElement | undefined;

  /** Walk the build bar to the page holding a machine. */
  function pageTo(name: string) {
    for (let i = 0; i < 12; i++) {
      if (lockBtn()) return true;
      window.dispatchEvent(new KeyboardEvent('keydown', { key: ']' }));
    }
    return !!lockBtn();
  }

  it('padlocks a tech-gated machine and names the tech it needs', () => {
    const h = harness({ reference: false });
    h.step(1);
    expect(pageTo('SOFTWARE DECODER')).toBe(true);

    const btn = lockBtn()!;
    expect(btn.classList.contains('is-locked')).toBe(true);
    expect(btn.querySelector('.fk-build-lock')!.classList.contains('is-on')).toBe(true);
    expect(btn.getAttribute('title')).toContain('REQUIRES:');
    expect(btn.getAttribute('title')).toContain('STREAM SOFTWARE DECODING');
  });

  it('leaves an ungated machine alone — the rule only locks what a tech claims', () => {
    const h = harness({ reference: false });
    h.step(1);
    // Walk the pages to LOGISTICS rather than assuming the belt is on the open one.
    let belt: Element | undefined;
    for (let i = 0; i < 12 && !belt; i++) {
      belt = Array.from(document.querySelectorAll('.fk-build-btn')).find((b) =>
        b.textContent?.includes('STREAM BELT'),
      );
      if (!belt) window.dispatchEvent(new KeyboardEvent('keydown', { key: ']' }));
    }
    expect(belt, 'belt should be somewhere in the bar').toBeTruthy();
    expect(belt!.classList.contains('is-locked')).toBe(false);
  });

  it('refuses to select a locked machine, by click or hotkey', () => {
    const h = harness({ reference: false });
    h.step(1);
    pageTo('SOFTWARE DECODER');
    lockBtn()!.dispatchEvent(new MouseEvent('click', { bubbles: true }));
    expect(h.state()).toBeNull(); // nothing picked up
  });

  it('runs the whole loop: lock → pick research → deliver → unlock → place succeeds', () => {
    const h = harness({ reference: false });
    h.step(1);

    // 1. locked, and the sim agrees: a place command is silently dropped.
    h.clickTile({ x: 0, y: 0 });
    h.sim.enqueue({ kind: 'place', def: 'software-decoder', pos: { x: 20, y: 20 }, dir: 0 });
    h.step(2);
    expect(h.find('software-decoder'), 'gated machine must not place').toBeUndefined();

    // 2. a lab is buildable from a cold start, and picking a tech goes through the panel.
    h.sim.enqueue({ kind: 'place', def: 'archaeology-lab', pos: { x: 10, y: 10 }, dir: 0 });
    h.step(2);
    expect(h.find('archaeology-lab')).toBeTruthy();

    window.dispatchEvent(new KeyboardEvent('keydown', { key: 't' })); // open the panel
    h.step(1);
    expect(document.getElementById('fk-tech')!.classList.contains('is-open')).toBe(true);

    const node = Array.from(document.querySelectorAll('.fk-tech-node')).find((n) =>
      n.textContent?.includes('SOFTWARE DECODING'),
    ) as HTMLElement;
    node.dispatchEvent(new MouseEvent('click', { bubbles: true })); // -> setResearch
    h.step(2);
    expect(h.sim.snapshot().research!.active).toBe(SOFTWARE_TECH);
    expect(node.classList.contains('is-active')).toBe(true);

    // 3. deliver the packs. Nothing in the game can make them yet (see NOTES), so they
    //    are teleported into the lab's intake via save/load — every step after this is
    //    the sim's own drainLabs -> researched -> applyUnlocks path, unmocked.
    const cost = DATA.tech.find((t) => t.id === SOFTWARE_TECH)!.cost;
    const s = JSON.parse(h.sim.save());
    const lab = s.entities.find((e: any) => e.state.def === 'archaeology-lab');
    lab.state.inputBuf = { ...cost };
    h.sim.load(JSON.stringify(s));

    // 4. the lab actually works for it: v5 SIM converts one pack per
    //    RESEARCH_TICKS_PER_PACK ticks, so research is a process, not an instant.
    const packs = Object.values(cost).reduce((a, b) => a + b, 0);
    h.step(RESEARCH_TICKS_PER_PACK); // one pack's worth
    expect(h.sim.snapshot().research!.progress, 'the lab should be converting packs')
      .not.toEqual({});

    // ...run it to completion (generous bound; headless this is milliseconds).
    for (let i = 0; i < packs + 4 && !h.sim.snapshot().research!.unlocked.includes(SOFTWARE_TECH); i++) {
      h.step(RESEARCH_TICKS_PER_PACK);
    }

    // 5. the unlock lands: real event, toast in the house voice, padlock falls off.
    expect(h.sim.snapshot().research!.unlocked).toContain(SOFTWARE_TECH);
    expect(toasts().some((t) => t?.includes('STANDARD RATIFIED'))).toBe(true);

    pageTo('SOFTWARE DECODER');
    expect(lockBtn()!.classList.contains('is-locked'), 'padlock should fall off live')
      .toBe(false);
    expect(lockBtn()!.getAttribute('title')).not.toContain('REQUIRES:');

    // 6. and now it builds.
    h.sim.enqueue({ kind: 'place', def: 'software-decoder', pos: { x: 20, y: 20 }, dir: 0 });
    h.step(2);
    expect(h.find('software-decoder'), 'unlocked machine must place').toBeTruthy();
  });

});

describe('onboarding and ceremony against the real sim', () => {
  it('gives the opening exactly one mercy hint on the first brownout, then never again', () => {
    // Cold start: one extractor, no generator. The alarm alone teaches nothing.
    const h = harness({ reference: false });
    h.bus.dispatch({ kind: 'place', def: 'seam-extractor', pos: { x: 0, y: 0 }, dir: 1 });
    h.step(30);

    const mercy = () => toasts().filter((t) => t?.includes('THE COMMITTEE SUGGESTS: POWER'));
    expect(h.sim.snapshot().bandwidth.brownout, 'a powerless factory browns out').toBe(true);
    expect(mercy(), 'the hint fires on the first brownout').toHaveLength(1);

    // Ride it out and brown out again — the hint must not become a nag.
    h.bus.dispatch({ kind: 'place', def: 'decode-asic', pos: { x: 6, y: 0 }, dir: 1 });
    h.step(300);
    h.bus.dispatch({ kind: 'remove', pos: { x: 6, y: 0 } });
    h.step(300);
    expect(mercy(), 'and only ever once a session').toHaveLength(1);
  });

  it('publishes the inspect selection while the panel is open, and drops it on close', () => {
    const h = harness({ unlocked: true });
    h.step(20);
    const unit = h.find('seam-extractor')!;

    h.clickTile(unit.pos);
    h.step(1);
    expect(h.state(), 'renderer gets the entity to highlight')
      .toEqual({ mode: 'inspect', entity: unit.id });

    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
    h.step(1);
    expect(h.state(), 'highlight stops when the panel shuts').toBeNull();
  });

  it('drops the highlight when the inspected unit is demolished under it', () => {
    const h = harness({ unlocked: true });
    h.step(20);
    const unit = h.find('seam-extractor')!;
    h.clickTile(unit.pos);
    h.step(1);
    expect(h.state()).toEqual({ mode: 'inspect', entity: unit.id });

    h.bus.dispatch({ kind: 'remove', pos: unit.pos });
    h.step(3);
    expect(h.state(), 'no highlight on a unit that no longer exists').toBeNull();
  });

  it('reveals the tuner dock only once the fossil is actually excavated', () => {
    const h = harness({ unlocked: true });
    h.step(20);

    const dock = () => document.getElementById('fk-tuner')!;
    const relic = h.sim.snapshot().relics?.[0];
    expect(relic, 'SIM seeds an optical fossil').toBeTruthy();
    expect(relic!.found).toBe(false);
    expect(dock().classList.contains('is-revealed'), 'silent until it is dug up').toBe(false);

    h.bus.dispatch({ kind: 'excavate', pos: relic!.pos });
    // Excavation isn't instant; give it room to finish.
    for (let i = 0; i < 200 && !h.sim.snapshot().relics![0].found; i++) h.step(10);

    expect(h.sim.snapshot().relics![0].found, 'the fossil should come up').toBe(true);
    expect(dock().classList.contains('is-revealed'), 'the dock arrives with it').toBe(true);
    expect(toasts().some((t) => t?.includes('THE BITSTREAM HAS A VOICE'))).toBe(true);

    // The dock lands in the toast stack's corner, so the toasts must step aside.
    expect(
      document.getElementById('fk-toasts')!.classList.contains('is-standoff'),
      'toasts should clear the dial rather than print over it',
    ).toBe(true);
  });
});

describe('remove mode against the real sim', () => {
  it('demolishes the clicked unit and toasts it', () => {
    const h = harness({ unlocked: true });
    h.step(50);
    const before = h.sim.snapshot().entities.length;
    const victim = h.find('belt')!;

    // arm remove the way the X hotkey does — typed protocol, no magic string
    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
    expect(h.state()).toEqual({ mode: 'remove' });

    h.clickTile(victim.pos);
    h.step(2);

    expect(h.sim.snapshot().entities.length).toBe(before - 1);
    expect(h.sim.snapshot().entities.some((e) => e.id === victim.id)).toBe(false);
    expect(toasts().some((t) => t?.includes('UNIT RECLAIMED'))).toBe(true);
  });

  it('stays armed for a second demolition, and Esc stands it down', () => {
    const h = harness({ unlocked: true });
    h.step(50);
    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));

    const first = h.find('belt')!;
    h.clickTile(first.pos);
    h.step(2);
    expect(h.state()).toEqual({ mode: 'remove' }); // still armed

    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
    expect(h.state()).toBeNull();
  });

  it('does not fire at open ground', () => {
    const h = harness({ unlocked: true });
    h.step(50);
    const before = h.sim.snapshot().entities.length;
    window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }));
    h.clickTile({ x: 60, y: 60 }); // nothing out there
    h.step(2);
    expect(h.sim.snapshot().entities.length).toBe(before);
  });
});
