/**
 * The plume layer, held to the two properties that make it honest and the one
 * that makes it cheap.
 *
 * **Plume count costs no draw calls.** Eight plumes and one plume are the same
 * single `THREE.Mesh`; the only thing that moves is `instanceCount`. That is the
 * whole reason a plume is affordable at all, and it is easy to lose to a
 * well-meaning refactor that gives each fire its own mesh "for clarity".
 *
 * **The wind blows the right way.** `fromDeg` is the bearing the wind comes
 * *from*, so smoke travels toward `fromDeg + 180`, and scene north is −Z with
 * +X east. Getting that wrong points every plume into the wind, which looks
 * completely plausible on a still frame and is wrong on every one of them. The
 * assertion below is on the uniform, because there is no picture that catches it.
 *
 * **An empty layer is not visited.** `visible = false` rather than an
 * instance count of zero, because a mesh the renderer walks is a draw call paid
 * on a board where nothing is happening — which is most boards, most days.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";

import { createFireSmoke, MAX_PLUMES, type SmokePlume } from "../../engine/fireSmoke.ts";
import type { LightingState } from "../../engine/types.ts";

const SPAN = 428;

function plume(id: string, over: Partial<SmokePlume> = {}): SmokePlume {
  return { id, x: 0, y: 0, z: 0, length: 12, width: 1.2, density: 0.5, lift: 0, ...over };
}

/** The default layer: full density, so the instance counts are predictable. */
function layer() {
  return createFireSmoke({ span: SPAN, density: 1 });
}

function mesh(root: THREE.Object3D): THREE.Mesh {
  const found = root.getObjectByName("fire-smoke-puffs");
  assert.ok(found, "the puff mesh must exist");
  return found as THREE.Mesh;
}

const NOON: LightingState = {
  sun: { direction: [0, 1, 0], color: 0xfff2e0, intensity: 2.6 },
  hemisphere: { sky: 0x8fb6e8, ground: 0x6b6153, intensity: 0.6 },
  ambient: { color: 0xffffff, intensity: 0.2 },
  sky: { top: 0x3f7fd0, horizon: 0xcfe0f2 },
  fog: { color: 0xc9d8e8, near: 400, far: 1600 },
};

describe("the smoke layer", () => {
  it("is one mesh whatever the plume count", () => {
    const smoke = layer();
    const meshes: string[] = [];
    smoke.group.traverse((object) => {
      if ((object as THREE.Mesh).isMesh === true) meshes.push(object.name);
    });
    assert.deepEqual(meshes, ["fire-smoke-puffs"]);

    smoke.setPlumes([plume("a")]);
    const one = (mesh(smoke.group).geometry as THREE.InstancedBufferGeometry).instanceCount;
    smoke.setPlumes([plume("a"), plume("b"), plume("c")]);
    const three = (mesh(smoke.group).geometry as THREE.InstancedBufferGeometry).instanceCount;

    assert.equal(three, one * 3, "instances scale with plumes");
    const stillOne: string[] = [];
    smoke.group.traverse((object) => {
      if ((object as THREE.Mesh).isMesh === true) stillOne.push(object.name);
    });
    assert.deepEqual(stillOne, ["fire-smoke-puffs"], "and objects do not");
    smoke.dispose();
  });

  it("is invisible with no plumes rather than empty and visited", () => {
    const smoke = layer();
    assert.equal(mesh(smoke.group).visible, false);
    smoke.setPlumes([plume("a")]);
    assert.equal(mesh(smoke.group).visible, true);
    smoke.setPlumes([]);
    assert.equal(mesh(smoke.group).visible, false);
    assert.equal(smoke.plumeCount(), 0);
    smoke.dispose();
  });

  it("caps at MAX_PLUMES and keeps the ones it was handed first", () => {
    const smoke = layer();
    smoke.setPlumes(Array.from({ length: 40 }, (_, i) => plume(`f${i}`)));
    assert.equal(smoke.plumeCount(), MAX_PLUMES);
    smoke.dispose();
  });

  it("respects a smaller cap and a lower density", () => {
    const smoke = createFireSmoke({ span: SPAN, maxPlumes: 2, puffsPerPlume: 20, density: 1 });
    smoke.setPlumes([plume("a"), plume("b"), plume("c")]);
    assert.equal(smoke.plumeCount(), 2);
    assert.equal((mesh(smoke.group).geometry as THREE.InstancedBufferGeometry).instanceCount, 40);
    smoke.dispose();
  });

  it("blows downwind, not upwind", () => {
    const smoke = layer();
    const material = mesh(smoke.group).material as THREE.ShaderMaterial;
    const wind = material.uniforms.uWind?.value as THREE.Vector2;

    // Wind *from* the north travels south, and scene south is +Z.
    smoke.setWind(20, 0);
    assert.ok(Math.abs(wind.x) < 1e-6);
    assert.ok(wind.y > 0.99, `north wind must travel +Z, got ${wind.y}`);

    // Wind *from* the west travels east, and scene east is +X.
    smoke.setWind(20, 270);
    assert.ok(wind.x > 0.99, `west wind must travel +X, got ${wind.x}`);
    assert.ok(Math.abs(wind.y) < 1e-6);
    smoke.dispose();
  });

  it("falls back to a light breeze rather than standing still", () => {
    const smoke = layer();
    const material = mesh(smoke.group).material as THREE.ShaderMaterial;
    const wind = material.uniforms.uWind?.value as THREE.Vector2;
    smoke.setWind(null, null);
    assert.ok(Math.hypot(wind.x, wind.y) > 0.99, "a null wind must still have a direction");
    smoke.setWind(Number.NaN, Number.NaN);
    assert.ok(Number.isFinite(wind.x) && Number.isFinite(wind.y));
    smoke.dispose();
  });

  it("takes the fog and the key off a rig it did not compute", () => {
    const smoke = layer();
    const material = mesh(smoke.group).material as THREE.ShaderMaterial;
    smoke.setLighting(NOON);
    assert.equal(material.uniforms.uFogNear?.value, 400);
    assert.equal(material.uniforms.uFogFar?.value, 1600);
    assert.ok((material.uniforms.uKey?.value as number) > 0.5);

    const dusk: LightingState = { ...NOON, sun: { ...NOON.sun, intensity: 0.2 } };
    smoke.setLighting(dusk);
    assert.ok((material.uniforms.uKey?.value as number) < 0.2);
    smoke.dispose();
  });

  it("constructs no light — CONTRACT.md §4", () => {
    const smoke = layer();
    smoke.setPlumes([plume("a")]);
    smoke.setLighting(NOON);
    const lights: string[] = [];
    smoke.group.traverse((object) => {
      if ((object as THREE.Light).isLight === true) lights.push(object.type);
    });
    assert.deepEqual(lights, []);
    smoke.dispose();
  });

  it("writes each plume's own shape into its own instances", () => {
    const smoke = createFireSmoke({ span: SPAN, maxPlumes: 3, puffsPerPlume: 10, density: 1 });
    smoke.setPlumes([
      plume("a", { x: 5, y: 1, z: -7, length: 30, width: 2, density: 0.7, lift: 0.9 }),
      plume("b", { x: -11, y: 2, z: 3, length: 6, width: 0.5, density: 0.3, lift: 0 }),
    ]);
    const geometry = mesh(smoke.group).geometry as THREE.InstancedBufferGeometry;
    const origin = geometry.getAttribute("iOrigin");
    const shape = geometry.getAttribute("iShape");

    assert.equal(origin.getX(0), 5);
    assert.equal(origin.getZ(9), -7);
    assert.equal(shape.getX(0), 30);
    assert.ok(Math.abs(shape.getW(0) - 0.9) < 1e-6);

    assert.equal(origin.getX(10), -11);
    assert.equal(shape.getX(19), 6);
    assert.equal(shape.getW(19), 0);
    smoke.dispose();
  });

  it("clamps a density and a lift that arrived out of range", () => {
    const smoke = createFireSmoke({ span: SPAN, maxPlumes: 1, puffsPerPlume: 4, density: 1 });
    smoke.setPlumes([plume("a", { density: 9, lift: -3 })]);
    const shape = (mesh(smoke.group).geometry as THREE.InstancedBufferGeometry).getAttribute(
      "iShape",
    );
    assert.equal(shape.getZ(0), 1);
    assert.equal(shape.getW(0), 0);
    smoke.dispose();
  });

  it("stays hidden while it is switched off, however many plumes arrive", () => {
    const smoke = layer();
    smoke.setVisible(false);
    smoke.setPlumes([plume("a"), plume("b")]);
    assert.equal(mesh(smoke.group).visible, false);
    smoke.setVisible(true);
    assert.equal(mesh(smoke.group).visible, true);
    smoke.dispose();
  });

  it("advances its own clock and never on a bad delta", () => {
    const smoke = layer();
    const material = mesh(smoke.group).material as THREE.ShaderMaterial;
    smoke.tick(0.5);
    const after = material.uniforms.uTime?.value as number;
    assert.ok(after > 0);
    smoke.tick(Number.NaN);
    assert.equal(material.uniforms.uTime?.value, after, "a NaN delta must not poison the clock");
    smoke.dispose();
  });

  it("casts no shadow and is never frustum culled", () => {
    const smoke = layer();
    const puffs = mesh(smoke.group);
    // The bounding sphere is the unit quad's, so the CPU thinks this object is
    // two units across; culling on it would cull the plume from almost anywhere.
    assert.equal(puffs.frustumCulled, false);
    assert.equal(puffs.castShadow, false);
    assert.equal(puffs.receiveShadow, false);
    smoke.dispose();
  });
});
