/**
 * The two pieces of device hardware, and the three promises the layers above
 * them are built on.
 *
 * `src/interiors/devices.ts` looks a device's LED up **by name**, `plan.ts`
 * lays a device out from its **footprint** without building it, and every office
 * in the product is expected to look the same on every reload. None of those
 * three is visible from inside the builder, and all three break silently: a
 * renamed sub-object gives you a mic whose mute light never changes, a footprint
 * in centimetres gives you a microphone the size of a filing cabinet, and a
 * `Math.random` slipping into a builder gives you a world that reshuffles itself
 * between visits.
 */

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

import { createAssetContext, kit } from "../../assets/kit.ts";
import { MaterialRegistry } from "../../assets/materials.ts";
import { DEVICE_ASSET_IDS, DEVICE_INDICATOR_NAME } from "../../assets/office/index.ts";
import { seeded, withStubCanvas } from "./fakeCanvas.ts";

function meshes(root: THREE.Object3D): THREE.Mesh[] {
  const found: THREE.Mesh[] = [];
  root.traverse((object) => {
    if (object instanceof THREE.Mesh) found.push(object);
  });
  return found;
}

function encodeGeometry(root: THREE.Object3D): string {
  root.updateMatrixWorld(true);
  const parts: string[] = [];
  for (const mesh of meshes(root)) {
    const position = mesh.geometry.getAttribute("position");
    let checksum = 0;
    for (let i = 0; i < position.count; i++) {
      // Quantised to a tenth of a millimetre. Comparing raw floats would make
      // this fail on a different machine's `Math.sin`, which is not the thing
      // being tested.
      checksum =
        (checksum * 31 +
          Math.round(position.getX(i) * 1e4) +
          Math.round(position.getY(i) * 1e4) * 7 +
          Math.round(position.getZ(i) * 1e4) * 13) |
        0;
    }
    parts.push(`${mesh.name}:${position.count}:${checksum}`);
  }
  return parts.join("|");
}

function disposeObject(root: THREE.Object3D): void {
  for (const mesh of meshes(root)) mesh.geometry.dispose();
}

describe("device hardware assets", () => {
  it("registers both devices under the parseable `<ns>:device.<kind>.<placement>` id", () => {
    assert.deepEqual([...DEVICE_ASSET_IDS], [
      "tera:device.mic.desk",
      "tera:device.speaker.desk",
    ]);
    for (const id of DEVICE_ASSET_IDS) {
      assert.equal(kit.has(id), true, `${id} is not registered`);
      // The shape `deviceKindOfAssetId()` parses. A device asset whose id does
      // not match this is dropped as "not device hardware" with no error, so the
      // pattern is worth pinning here rather than discovering in a plan's
      // `problems` array.
      assert.match(id, /^[a-z0-9-]+:device\.(mic|speaker)\.[a-z0-9-]+$/);
    }
  });

  it("is authored at desk scale, in metres", () => {
    for (const id of DEVICE_ASSET_IDS) {
      const footprint = kit.footprintOf(id);
      for (const [name, value] of [
        ["width", footprint.width],
        ["depth", footprint.depth],
        ["height", footprint.height],
      ] as const) {
        assert.ok(
          Number.isFinite(value) && value >= 0.02 && value <= 0.6,
          `${id} ${name} is ${value}, which is not a desk object in metres`,
        );
      }
    }
  });

  it("encloses its own geometry in the footprint it advertises", () => {
    const materials = new MaterialRegistry({ quality: "low" });
    try {
      for (const id of DEVICE_ASSET_IDS) {
        const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
        const box = new THREE.Box3().setFromObject(object);
        const size = box.getSize(new THREE.Vector3());
        const footprint = kit.footprintOf(id);
        // A millimetre of slack, because a footprint is a layout number and not
        // a measurement of the mesh — but only a millimetre, because `Plan`
        // spaces things by it and a device that overhangs its own footprint is a
        // device that ends up inside a monitor.
        assert.ok(size.x <= footprint.width + 0.001, `${id} is ${size.x} wide`);
        assert.ok(size.z <= footprint.depth + 0.001, `${id} is ${size.z} deep`);
        assert.ok(box.max.y <= footprint.height + 0.001, `${id} reaches ${box.max.y}`);
        assert.ok(box.min.y >= -0.001, `${id} starts below the floor at ${box.min.y}`);
        disposeObject(object);
      }
    } finally {
      materials.dispose();
    }
  });

  it("exposes a sub-object named `indicator` holding the LED and nothing else", () => {
    const materials = new MaterialRegistry({ quality: "high" });
    const restore = withStubCanvas();
    try {
      for (const id of DEVICE_ASSET_IDS) {
        const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
        const indicator = object.getObjectByName(DEVICE_INDICATOR_NAME);
        assert.ok(indicator, `${id} has no "${DEVICE_INDICATOR_NAME}" sub-object`);

        const led = meshes(indicator);
        assert.ok(led.length > 0, `${id} indicator holds no geometry`);
        for (const mesh of led) {
          const material = mesh.material as THREE.Material;
          assert.equal(
            material.name,
            "deviceIndicator",
            `${id} indicator carries ${material.name}, which the device layer would not be able to tint safely`,
          );
          // An LED is smaller than a shadow-map texel and has nothing to cast.
          assert.equal(mesh.castShadow, false);
          assert.equal(mesh.receiveShadow, false);
        }

        // And the hardware must NOT be in there: the device layer replaces the
        // material on everything under this name, so a housing that ended up
        // inside it would light up with the LED.
        const hardware = meshes(object).filter((mesh) => !led.includes(mesh));
        assert.ok(hardware.length > 0, `${id} has no hardware outside its indicator`);
        for (const mesh of hardware) {
          assert.notEqual((mesh.material as THREE.Material).name, "deviceIndicator");
        }
        disposeObject(object);
      }
    } finally {
      restore();
      materials.dispose();
    }
  });

  it("builds byte-identical geometry for the same seed", () => {
    const materials = new MaterialRegistry({ quality: "low" });
    try {
      for (const id of DEVICE_ASSET_IDS) {
        const first = kit.build(id, createAssetContext({ materials, rand: seeded() }));
        const second = kit.build(id, createAssetContext({ materials, rand: seeded() }));
        assert.equal(encodeGeometry(first), encodeGeometry(second), `${id} is not deterministic`);
        disposeObject(first);
        disposeObject(second);
      }
    } finally {
      materials.dispose();
    }
  });

  it("keeps every material in one primitive class, so nothing is silently dropped", () => {
    // `mergeGeometries` refuses a mixture of indexed and non-indexed inputs and
    // `MeshBin` treats the refusal as "skip this material" — which is not an
    // error, it is a speaker with no cabinet. The symptom is a *missing* mesh,
    // so the assertion is on the count of materials that survived.
    const materials = new MaterialRegistry({ quality: "high" });
    const restore = withStubCanvas();
    try {
      const expected: Record<string, number> = {
        "tera:device.mic.desk": 4,
        "tera:device.speaker.desk": 5,
      };
      for (const id of DEVICE_ASSET_IDS) {
        const object = kit.build(id, createAssetContext({ materials, rand: seeded() }));
        const names = new Set(meshes(object).map((m) => (m.material as THREE.Material).name));
        assert.equal(
          names.size,
          expected[id],
          `${id} came back with ${names.size} materials (${[...names].join(", ")}) — a dropped one means a merge was refused`,
        );
        disposeObject(object);
      }
    } finally {
      restore();
      materials.dispose();
    }
  });

  it("gives the grille and the shell the roles the device layer expects", () => {
    const materials = new MaterialRegistry({ quality: "high" });
    const restore = withStubCanvas();
    try {
      const speaker = kit.build(
        "tera:device.speaker.desk",
        createAssetContext({ materials, rand: seeded() }),
      );
      const names = new Set(meshes(speaker).map((m) => (m.material as THREE.Material).name));
      assert.ok(names.has("deviceShell"), "the cabinet is missing its moulded-housing role");
      assert.ok(names.has("deviceMesh"), "the grille is missing its perforated role");
      // The grille has to be double-sided or the gaps between the slats show
      // nothing behind them, which is the whole reason it is geometry.
      const grille = meshes(speaker).find(
        (m) => (m.material as THREE.Material).name === "deviceMesh",
      );
      assert.ok(grille);
      assert.equal((grille.material as THREE.Material).side, THREE.DoubleSide);
      disposeObject(speaker);
    } finally {
      restore();
      materials.dispose();
    }
  });
});
