/**
 * The relic and the ground it's buried in — seams, and the one secret in the world.
 *
 * The relic is the only content in FKTRY with no lock but looking: no research gate, no
 * bandwidth cost, no UI affordance. So the tests that matter are about it being *findable
 * but not signposted*: derived from the seed, never on a seam (you'd hit it mining, which
 * would be an accident rather than a discovery), never under where the demo builds, and
 * once found, found forever.
 */
import { describe, expect, it } from 'vitest';
import { createSim } from './index';
import { referenceFactory, referenceFactoryTech } from './reference';
import { grantResearch } from './testkit';
import { RELIC_MAX_DIST, RELIC_MIN_DIST } from './constants';
import type { Command, Dir, GameData, ItemDef, MachineDef, RecipeDef, Sim, SimEvent } from '../contracts';
import type { SeamDef, SeamMap } 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;
const N: Dir = 0, E: Dir = 1;

/** GameData plus the seam map — the shape once the orchestrator wires GameData.seams. */
// Omit first: contracts v7 types GameData.seams as SeamMap, but the sim deliberately
// also accepts a bare rect list (defensive path) — this test covers both forms.
type Seamed = Omit<GameData, 'seams'> & { seams?: SeamMap | SeamDef[] };

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 seamRect = (id: string, x: number, y: number, w: number, h: number): SeamDef =>
  ({ id, rect: { x, y, w, h } });

const FIXTURE: Seamed = {
  items: ['rock'].map(item),
  machines: [
    mach({ id: 'mine', kind: 'extractor', recipes: ['dig'] }), // 1x1
    mach({ id: 'bigmine', kind: 'extractor', recipes: ['dig'], footprint: { x: 2, y: 2 } }),
    mach({ id: 'belt', kind: 'belt', beltSpeed: 2 }),
    mach({ id: 'ship', kind: 'shipper' }),
  ],
  recipes: [rec({ id: 'dig', machine: 'mine', outputs: { rock: 1 }, ticks: 10 })],
  tech: [],
  commissions: [{ id: 'c1', flavor: 'rocks', wants: { rock: 99 }, rewardBandwidth: 1 }],
};

function newSim(data: Seamed = FIXTURE, seed = 1): Sim {
  const sim = createSim();
  sim.init(data as GameData, 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);

describe('seams', () => {
  it('lets an extractor on a seam mine, and refuses one off it', () => {
    const data: Seamed = { ...FIXTURE, seams: { seams: [seamRect('patch', 0, 0, 4, 4)] } };
    const sim = newSim(data);
    sim.enqueue(place('mine', 1, 1));   // inside the patch
    sim.enqueue(place('mine', 20, 20)); // out in the dead ground

    const evs: SimEvent[] = [];
    run(sim, 200, evs);

    expect(at(sim, 1, 1)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0);
    expect(at(sim, 20, 20)!.outputBuf['rock'] ?? 0).toBe(0);
    expect(at(sim, 20, 20)!.jammed).toBe('no seam');
    // Edge-triggered like every other jam: said once, not once per tick.
    expect(evs.filter((e) => e.kind === 'jammed' && e.reason === 'no seam')).toHaveLength(1);
  });

  it('counts a footprint that only clips the seam corner', () => {
    // 2x2 at (3,3) covers (3,3)..(4,4); the seam is (0,0)..(3,3) — exactly one tile of
    // overlap, which is enough. At (5,5) it clears the seam entirely and starves.
    const data: Seamed = { ...FIXTURE, seams: { seams: [seamRect('patch', 0, 0, 4, 4)] } };
    const sim = newSim(data);
    sim.enqueue(place('bigmine', 3, 3));
    sim.enqueue(place('bigmine', 5, 5));
    run(sim, 200);
    expect(at(sim, 3, 3)!.jammed).not.toBe('no seam');
    expect(at(sim, 3, 3)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0);
    expect(at(sim, 5, 5)!.jammed).toBe('no seam');
  });

  it('mines anywhere when no seam map is authored — the pre-seams world still works', () => {
    const sim = newSim(); // FIXTURE has no seams key at all
    sim.enqueue(place('mine', 25, 25));
    run(sim, 200);
    expect(at(sim, 25, 25)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0);
    expect(at(sim, 25, 25)!.jammed).not.toBe('no seam');
  });

  it('reads DATA’s real seams.json shape as well as a bare rect list', () => {
    const nested: Seamed = { ...FIXTURE, seams: { relicReserve: { x: 12, y: 12, w: 20, h: 20 }, seams: [seamRect('a', 0, 0, 3, 3)] } };
    const bare: Seamed = { ...FIXTURE, seams: [seamRect('a', 0, 0, 3, 3)] };
    for (const data of [nested, bare]) {
      const sim = newSim(data);
      sim.enqueue(place('mine', 1, 1));
      sim.enqueue(place('mine', 20, 20));
      run(sim, 100);
      expect(at(sim, 1, 1)!.outputBuf['rock'] ?? 0).toBeGreaterThan(0);
      expect(at(sim, 20, 20)!.jammed).toBe('no seam');
    }
  });
});

describe('the relic', () => {
  it('is always present, unfound, and in the band — for any seed', () => {
    for (const seed of [1, 7, 1337, 90210, 424242]) {
      const sim = newSim(FIXTURE, seed);
      const relics = sim.snapshot().relics!;
      expect(relics).toHaveLength(1);
      const r = relics[0];
      expect(r.kind).toBe('optical-fossil');
      expect(r.found).toBe(false);
      const d = Math.hypot(r.pos.x, r.pos.y);
      expect(d, `seed ${seed} put the relic at ${d.toFixed(1)}`)
        .toBeGreaterThanOrEqual(RELIC_MIN_DIST);
      expect(d).toBeLessThanOrEqual(RELIC_MAX_DIST);
    }
  });

  it('is a pure function of the seed — same seed same spot, different seeds move it', () => {
    const posFor = (seed: number) => JSON.stringify(newSim(FIXTURE, seed).snapshot().relics![0].pos);
    expect(posFor(1337)).toBe(posFor(1337));
    const spread = new Set([1, 2, 3, 4, 5, 6, 7, 8].map(posFor));
    expect(spread.size, 'the relic must not sit in the same hole every world')
      .toBeGreaterThan(1);
  });

  it('never buries itself in a seam', () => {
    const data: Seamed = {
      ...FIXTURE,
      // Ring the whole legal band in seams bar the SE corner DATA reserves.
      seams: {
        relicReserve: { x: 12, y: 12, w: 20, h: 20 },
        seams: [
          seamRect('n', -32, -32, 64, 44), seamRect('w', -32, -32, 44, 64),
          seamRect('s', -32, 24, 64, 8),
        ],
      },
    };
    for (const seed of [1, 2, 3, 42, 1337, 5150]) {
      const sim = newSim(data, seed);
      const { x, y } = sim.snapshot().relics![0].pos;
      const onSeam = (data.seams as SeamMap).seams.some(
        ({ rect: s }) => x >= s.x && x < s.x + s.w && y >= s.y && y < s.y + s.h,
      );
      expect(onSeam, `seed ${seed}: relic at ${x},${y} landed in a seam`).toBe(false);
    }
  });

  it('never lands under a reference-factory machine', () => {
    // Every tile the demo actually occupies, computed from the layout rather than assumed.
    // A bounding box would be wrong now that the factory has an eastern annex: the gap
    // between the two works is legitimate ground to bury something in.
    const taken = new Set<string>();
    for (const c of referenceFactory(DATA)) {
      if (c.kind !== 'place') continue;
      const m = DATA.machines.find((q) => q.id === c.def)!;
      for (let dx = 0; dx < m.footprint.x; dx++) {
        for (let dy = 0; dy < m.footprint.y; dy++) taken.add(`${c.pos.x + dx},${c.pos.y + dy}`);
      }
    }
    expect(taken.size).toBeGreaterThan(100); // the layout really was walked

    for (let seed = 1; seed <= 60; seed++) {
      const { x, y } = newSim(DATA as Seamed, seed).snapshot().relics![0].pos;
      expect(taken.has(`${x},${y}`), `seed ${seed}: relic at ${x},${y} is buried under the demo`)
        .toBe(false);
    }
  });

  it('is dug up by excavating next to it, and ignores digs anywhere else', () => {
    const sim = newSim(FIXTURE, 1337);
    const { pos } = sim.snapshot().relics![0];

    sim.enqueue({ kind: 'excavate', pos: { x: pos.x + 5, y: pos.y } }); // cold
    const cold: SimEvent[] = [];
    run(sim, 1, cold);
    expect(sim.snapshot().relics![0].found).toBe(false);
    expect(cold.filter((e) => e.kind === 'relicFound')).toHaveLength(0);

    sim.enqueue({ kind: 'excavate', pos: { x: pos.x + 1, y: pos.y - 1 } }); // within a tile
    const warm: SimEvent[] = [];
    run(sim, 1, warm);
    expect(sim.snapshot().relics![0].found).toBe(true);
    const found = warm.filter((e) => e.kind === 'relicFound');
    expect(found).toHaveLength(1);
    expect(found[0]).toMatchObject({ relic: 'optical-fossil' });

    // Digging it up twice doesn't re-announce it.
    sim.enqueue({ kind: 'excavate', pos: { x: pos.x, y: pos.y } });
    const again: SimEvent[] = [];
    run(sim, 1, again);
    expect(again.filter((e) => e.kind === 'relicFound')).toHaveLength(0);
  });

  it('stays found through save, load and 20,000 further ticks', () => {
    const a = newSim(FIXTURE, 1337);
    const { pos } = a.snapshot().relics![0];
    a.enqueue({ kind: 'excavate', pos });
    run(a, 1);
    expect(a.snapshot().relics![0].found).toBe(true);

    const b = newSim(FIXTURE, 1337);
    b.load(a.save());
    expect(b.snapshot().relics![0].found).toBe(true);
    expect(b.snapshot().relics![0].pos).toEqual(pos);

    run(a, 20000);
    run(b, 20000);
    expect(b.snapshot().relics![0].found).toBe(true);
    expect(b.snapshot()).toEqual(a.snapshot());
  });

  it('an unfound relic survives save/load too — the secret keeps', () => {
    const a = newSim(FIXTURE, 99);
    const before = a.snapshot().relics![0];
    const b = newSim(FIXTURE, 99);
    b.load(a.save());
    expect(b.snapshot().relics![0]).toEqual(before);
    expect(b.snapshot().relics![0].found).toBe(false);
  });

  it('is reachable in the real demo world — the reserve DATA keeps actually works', () => {
    const sim = createSim();
    sim.init(DATA, 1337);
    grantResearch(sim, referenceFactoryTech(DATA));
    for (const c of referenceFactory(DATA)) sim.enqueue(c);
    run(sim, 100);

    const relic = sim.snapshot().relics![0];
    expect(relic.found).toBe(false);
    // Nothing was built on top of it, so a dig there lands.
    sim.enqueue({ kind: 'excavate', pos: relic.pos });
    const evs: SimEvent[] = [];
    run(sim, 1, evs);
    expect(evs.filter((e) => e.kind === 'relicFound')).toHaveLength(1);
  });
});
