import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";

import { MAPS } from "../src/lib/game/maps.js";
import { generateTerrain } from "../src/lib/game/terrain.js";
import { createPlayController } from "../src/lib/game/play.js";

// The controller wires keyboard/orientation listeners to `window`; give it a
// silent one so the sim can run headless, and take it away again afterwards.
const g = /** @type {any} */ (globalThis);
const hadWindow = "window" in g;
const prevWindow = g.window;
g.window ??= { addEventListener() {}, removeEventListener() {} };
test.after(() => {
  if (hadWindow) g.window = prevWindow;
  else delete g.window;
});

const def = MAPS[0];
const terrain = generateTerrain(def);
const gy = terrain.clearingY;

/**
 * A flat disc of dry land (radius `radius`, height `gy`) ringed by water, plus
 * optional cells raised via `bumps`. Only the bits of the terrain API that
 * the play controller touches.
 * @param {{radius?: number, bumps?: Record<string, number>}} [o]
 */
function fakeTerrain({ radius = 5, bumps = {} } = {}) {
  return /** @type {any} */ ({
    clearingY: gy,
    treeList: [],
    /** @param {number} x @param {number} z */
    heightAt(x, z) {
      const key = `${Math.floor(x)},${Math.floor(z)}`;
      if (key in bumps) return bumps[key];
      return Math.hypot(x, z) < radius ? gy : 0;
    },
  });
}

/**
 * @param {any} terrain
 * @param {{t:string,x:number,z:number,r:number}[]} [placements]
 */
function spawn(terrain, placements = []) {
  const scene = new THREE.Scene();
  const placementsGroup = new THREE.Group();
  const world = /** @type {any} */ ({ terrain, scene, placementsGroup });
  const ctl = createPlayController({
    world,
    def,
    kind: "boy",
    camera: new THREE.PerspectiveCamera(),
    getPlacements: () => placements,
    getRemovedTrees: () => [],
  });
  return { ctl, play: g.window.__play };
}

/**
 * Spawn a kid in an empty park and hold the joystick forward for `seconds`
 * using frames of `frameDt` each.
 * @param {number} frameDt @param {number} seconds
 */
function run(frameDt, seconds) {
  const { ctl, play } = spawn(terrain);
  play.teleport(-4, 8, 0);
  const start = play.pos;
  ctl.joy.z = -1; // push forward
  let t = 0;
  while (t < seconds - 1e-9) {
    t += frameDt;
    ctl.update(frameDt);
  }
  const end = play.pos;
  ctl.dispose();
  return Math.hypot(end.x - start.x, end.z - start.z);
}

test("the kid covers the same ground at 60fps and at 8fps", () => {
  const smooth = run(1 / 60, 2);
  const choppy = run(1 / 8, 2);
  assert.ok(smooth > 7, `walked ${smooth.toFixed(2)} at 60fps`);
  assert.ok(
    Math.abs(smooth - choppy) < 0.15,
    `60fps ${smooth.toFixed(2)} vs 8fps ${choppy.toFixed(2)}`,
  );
});

test("a very long frame is dropped rather than teleporting the kid", () => {
  const d = run(2, 2); // one 2s frame
  assert.ok(d < 1.2, `moved ${d.toFixed(2)} in one huge frame`);
});

test("the kid stops at the water's edge instead of walking in", () => {
  const { ctl, play } = spawn(fakeTerrain({ radius: 5 }));
  play.teleport(0, 0, 0);
  ctl.joy.z = -1;
  for (let i = 0; i < 30 * 10; i++) ctl.update(1 / 30);
  const p = play.pos;
  const r = Math.hypot(p.x, p.z);
  assert.ok(r > 4.2, `reached the shore (r=${r.toFixed(2)})`);
  assert.ok(r < 5, `stayed on dry land (r=${r.toFixed(2)})`);
  assert.equal(p.y, gy, "still standing at clearing height");
  ctl.dispose();
});

test("the kid spawns on top of a raised spawn cell and can walk off it", () => {
  const s = def.clearing.lobes[0];
  const key = `${Math.floor(s.x)},${Math.floor(s.z)}`;
  const t = fakeTerrain({ radius: 100, bumps: { [key]: gy + 2 } });
  const { ctl, play } = spawn(t);
  assert.equal(play.pos.x, s.x);
  assert.equal(play.pos.z, s.z);
  assert.equal(play.pos.y, gy + 2, "starts on the stacked land, not inside it");
  ctl.joy.z = -1;
  for (let i = 0; i < 30 * 3; i++) ctl.update(1 / 30);
  const p = play.pos;
  assert.ok(
    Math.hypot(p.x - s.x, p.z - s.z) > 2,
    `left the spawn cell (${p.x.toFixed(2)}, ${p.z.toFixed(2)})`,
  );
  assert.equal(t.heightAt(p.x, p.z), gy);
  assert.ok(Math.abs(p.y - gy) < 0.01, `back at ground level (y=${p.y})`);
  ctl.dispose();
});

test("a long hitch does not fast-forward the sandbox timer", () => {
  const box = { t: "sandbox", x: 0, z: 3, r: 0 };
  const { ctl, play } = spawn(fakeTerrain({ radius: 20 }), [box]);
  play.teleport(0, 0, 0); // heading 0 faces +Z: forward runs into the box
  ctl.joy.z = -1;
  let steps = 0;
  while (play.state !== "sand" && steps++ < 30 * 5) ctl.update(1 / 30);
  assert.equal(play.state, "sand", "walked into the sandbox and knelt");
  ctl.joy.z = 0;
  ctl.update(5); // the tab was hidden for 5s: only 0.25s of sim should run
  assert.equal(play.state, "sand", "still digging after the hitch");
  for (let i = 0; i < 30 * 4; i++) ctl.update(1 / 30);
  assert.equal(play.state, "walk", "stood back up after ~3.2s of sim time");
  ctl.dispose();
});
