/**
 * The device layer, held to the three rules it can break invisibly.
 *
 * **It constructs no light.** CONTRACT.md §4 gives Atmosphere sole ownership,
 * and an LED is the most tempting exception in the whole codebase because it is
 * *obviously* a light and a `PointLight` per device is one line. The grep in
 * the build spec catches the letter of it; this file catches the spirit, by
 * asserting nothing in the subtree is a `THREE.Light` at all.
 *
 * **It borrows materials and owns geometry.** Every mesh comes out of
 * `MeshBin`, which clones and merges, so the geometry belongs to the layer and
 * goes with it. The materials come from the shared `MaterialRegistry`, are
 * cached there across the whole office, and disposing one here would empty the
 * desks in the rest of the building — so `dispose()` must free the first and
 * must not touch the second. A test that only asserted "everything is
 * disposed" would be asserting the bug.
 *
 * **It drops what it cannot place.** A device anchored to a prop that is not in
 * the plan — a typo, or a private prop in a public build — has nowhere to
 * stand, and putting it at the origin would leave a microphone on the lobby
 * floor.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import { AssetRegistry, defineAsset } from "../../assets/kit.ts";
import { MaterialRegistry } from "../../assets/materials.ts";
import type { DeviceDeclaration, DeviceState } from "../../devices/types.ts";
import { createDeviceLayer } from "../../interiors/devices.ts";
import { Plan } from "../../interiors/plan.ts";
import type { Office } from "../../interiors/types.ts";

const MIC = "tera:device.mic.desk";

/**
 * A stand-in for the real hardware.
 *
 * The layer's contract with an asset is exactly one sentence — expose a
 * sub-object named `indicator` — so the test asset is that sentence and nothing
 * else. Building against `src/assets/office/devices.ts` would make this a test
 * of somebody else's geometry, and it would fail for reasons that have nothing
 * to do with this layer.
 */
function registry(withIndicator = true): AssetRegistry {
  return new AssetRegistry().register(
    defineAsset({
      id: MIC,
      defaults: {},
      footprint: () => ({ width: 0.09, depth: 0.09, height: 0.3 }),
      build(_params, ctx) {
        const group = new THREE.Group();
        const body = new THREE.Mesh(new THREE.BoxGeometry(0.09, 0.2, 0.09), ctx.materials.get("deviceShell"));
        body.name = "body";
        group.add(body);
        if (withIndicator) {
          const led = new THREE.Mesh(
            new THREE.SphereGeometry(0.004, 6, 4),
            ctx.materials.get("deviceIndicator"),
          );
          led.name = "indicator";
          led.position.set(0, 0.22, 0.03);
          group.add(led);
        }
        return group;
      },
    }),
  );
}

function office(declarations: readonly DeviceDeclaration[]): Office {
  return {
    id: "test",
    name: "Test",
    viewpoints: [],
    levels: [
      {
        id: "l1",
        name: "Ground",
        elevation: 0,
        wallHeight: 3,
        floorplan: {
          rooms: [
            {
              id: "room",
              name: "Room",
              floor: "tera:carpet.loop",
              outline: [
                { x: 0, z: 0 },
                { x: 8, z: 0 },
                { x: 8, z: 6 },
                { x: 0, z: 6 },
              ],
            },
          ],
          walls: [],
          props: [{ id: "mic-prop", kind: MIC, position: { x: 2, z: 3 }, rotation: Math.PI / 2 }],
          // Authored here as well as handed to the layer, because that is how a
          // real pack carries them: `Plan` is what turns the anchor into a
          // coordinate, and a declaration the plan never saw is a device with
          // nowhere to stand.
          devices: declarations,
        },
      },
    ],
  } as unknown as Office;
}

const declaration: DeviceDeclaration = {
  id: "mic-1",
  kind: "mic",
  label: "Desk mic",
  assetId: MIC,
  anchor: { levelId: "l1", propId: "mic-prop", offset: { x: 0, y: 0.72, z: 0 } },
  capabilities: ["power", "mute", "gain", "level"],
  provenance: "simulated",
  disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
};

function state(over: Partial<DeviceState> = {}): DeviceState {
  return {
    id: "mic-1",
    kind: "mic",
    powered: false,
    muted: false,
    gainDb: 12,
    levelDb: -60,
    observedAt: 0,
    synthetic: true,
    ...over,
  };
}

function layerFor(options: { withIndicator?: boolean; declarations?: DeviceDeclaration[] } = {}) {
  const materials = new MaterialRegistry({ quality: "low" });
  const assets = registry(options.withIndicator ?? true);
  const declarations = options.declarations ?? [declaration];
  const plan = new Plan(office(declarations), { depth: "full", warn: false });
  const layer = createDeviceLayer({ plan, declarations, assets, materials });
  return { layer, materials, plan };
}

/** The indicator's material, wherever in the subtree it ended up. */
function indicatorMaterial(object: THREE.Object3D): THREE.MeshStandardMaterial | null {
  const indicator = object.getObjectByName("indicator");
  if (!indicator) return null;
  let found: THREE.MeshStandardMaterial | null = null;
  indicator.traverse((child) => {
    const mesh = child as THREE.Mesh;
    if (mesh.isMesh && !Array.isArray(mesh.material)) {
      found = mesh.material as THREE.MeshStandardMaterial;
    }
  });
  return found;
}

describe("placing the hardware", () => {
  it("stands a device on its anchor prop, in the prop's own frame", () => {
    const { layer, materials } = layerFor();
    const mount = layer.object.getObjectByName("device:mic-1");
    assert.ok(mount !== undefined);
    layer.object.updateMatrixWorld(true);

    const world = new THREE.Vector3();
    (layer.object.getObjectByName("indicator") as THREE.Object3D).getWorldPosition(world);
    // The prop stands at (2, 3) rotated a quarter turn, and the declaration
    // lifts the device 0.72 m up the desk. The indicator is 30 mm forward of
    // the mic's own origin, which the prop's rotation carries round with it —
    // which is the whole reason the offset is expressed in the prop's frame.
    assert.ok(Math.abs(world.y - (0.72 + 0.22)) < 1e-6, `y ${world.y}`);
    assert.ok(Math.abs(world.x - 2.03) < 1e-6, `x ${world.x}`);
    assert.ok(Math.abs(world.z - 3) < 1e-6, `z ${world.z}`);
    layer.dispose();
    materials.dispose();
  });

  it("drops a device whose hardware is not in the plan", () => {
    const { layer, materials } = layerFor({
      declarations: [
        declaration,
        { ...declaration, id: "mic-nowhere", anchor: { levelId: "l1", propId: "no-such-prop" } },
        { ...declaration, id: "mic-elsewhere", anchor: { levelId: "l9", propId: "mic-prop" } },
      ],
    });
    const names = layer.object.children.map((child) => child.name);
    assert.deepEqual(names, ["device:mic-1"]);
    layer.dispose();
    materials.dispose();
  });

  it("still stands hardware that exposes no indicator", () => {
    const { layer, materials } = layerFor({ withIndicator: false });
    assert.equal(layer.object.children.length, 1);
    // And applying a state to it is a no-op rather than a throw: a
    // self-hoster's own microphone model is not a reason to lose the office.
    layer.apply([state({ powered: true })]);
    layer.dispose();
    materials.dispose();
  });

  it("builds the same geometry every time, from the same declaration", () => {
    const a = layerFor();
    const b = layerFor();
    const box = (object: THREE.Object3D) => {
      object.updateMatrixWorld(true);
      return [...new THREE.Box3().setFromObject(object).min.toArray()];
    };
    assert.deepEqual(box(a.layer.object), box(b.layer.object));
    a.layer.dispose();
    a.materials.dispose();
    b.layer.dispose();
    b.materials.dispose();
  });
});

describe("showing the state", () => {
  it("changes the indicator colour between powered and unpowered", () => {
    const { layer, materials } = layerFor();

    layer.apply([state({ powered: false })]);
    const off = indicatorMaterial(layer.object);
    assert.ok(off !== null);
    const offColor = off.color.getHex();

    layer.apply([state({ powered: true })]);
    const on = indicatorMaterial(layer.object);
    assert.ok(on !== null);
    assert.notEqual(on.color.getHex(), offColor);
    // The role glows, so the tint has to reach the emissive term as well —
    // otherwise a "lit" LED is a coloured pebble under the tone curve.
    assert.equal(on.emissive.getHex(), on.color.getHex());
    layer.dispose();
    materials.dispose();
  });

  it("shows a muted microphone differently from an open one", () => {
    const { layer, materials } = layerFor();
    layer.apply([state({ powered: true, muted: false })]);
    const open = indicatorMaterial(layer.object)?.color.getHex();
    layer.apply([state({ powered: true, muted: true })]);
    const muted = indicatorMaterial(layer.object)?.color.getHex();
    assert.notEqual(open, muted);
    layer.dispose();
    materials.dispose();
  });

  it("shares one material per state across every device in the building", () => {
    const { layer, materials } = layerFor({
      declarations: [declaration, { ...declaration, id: "mic-2" }],
    });
    layer.apply([state({ id: "mic-1", powered: true }), state({ id: "mic-2", powered: true })]);
    const [first, second] = layer.object.children;
    // The same object, not an equal one: two microphones in the same state are
    // one material and one draw call, which is what `tinted` is cached for.
    assert.equal(indicatorMaterial(first as THREE.Object3D), indicatorMaterial(second as THREE.Object3D));
    layer.dispose();
    materials.dispose();
  });

  it("leaves a device alone when a poll drops it", () => {
    const { layer, materials } = layerFor();
    layer.apply([state({ powered: true })]);
    const on = indicatorMaterial(layer.object)?.color.getHex();
    // A reading that stopped arriving is not the same event as a device being
    // switched off, and only one of them should change what is on screen.
    layer.apply([]);
    assert.equal(indicatorMaterial(layer.object)?.color.getHex(), on);
    layer.dispose();
    materials.dispose();
  });

  it("ignores a state for a device it does not carry", () => {
    const { layer, materials } = layerFor();
    layer.apply([state({ id: "somebody-elses-mic", powered: true })]);
    layer.dispose();
    materials.dispose();
  });
});

describe("the lighting rule", () => {
  it("constructs no light of any kind", () => {
    const { layer, materials } = layerFor();
    layer.apply([state({ powered: true })]);
    let lights = 0;
    layer.object.traverse((child) => {
      if ((child as THREE.Light).isLight) lights += 1;
    });
    assert.equal(lights, 0);
    layer.dispose();
    materials.dispose();
  });
});

describe("disposal", () => {
  it("frees every geometry it built", () => {
    const { layer, materials } = layerFor();
    const geometries: THREE.BufferGeometry[] = [];
    layer.object.traverse((child) => {
      const mesh = child as THREE.Mesh;
      if (mesh.isMesh) geometries.push(mesh.geometry);
    });
    assert.ok(geometries.length > 0);

    let freed = 0;
    for (const geometry of geometries) geometry.addEventListener("dispose", () => (freed += 1));
    layer.dispose();
    assert.equal(freed, geometries.length);
    assert.equal(layer.object.children.length, 0);
    materials.dispose();
  });

  it("does not dispose the shared materials it borrowed", () => {
    const { layer, materials } = layerFor();
    layer.apply([state({ powered: true })]);
    const material = indicatorMaterial(layer.object);
    assert.ok(material !== null);

    let disposed = 0;
    material.addEventListener("dispose", () => (disposed += 1));
    layer.dispose();
    // The registry's material is holding up the rest of the office. Freeing it
    // here would be a device layer emptying the desks.
    assert.equal(disposed, 0);
    assert.equal(materials.tinted("deviceIndicator", material.color.getHex()), material);
    materials.dispose();
  });
});
