/**
 * The whole catalogue, checked for the two failures that do not raise anything.
 *
 * `src/test/officeHabitat.test.ts` already covers the seven habitat ids by
 * name. This file covers **every registered asset**, including the ones that
 * have not been written yet, and it exists because the two ways an asset breaks
 * in this library are both silent:
 *
 * 1. **A material gets dropped.** `mergeGeometries` refuses a mixture of indexed
 *    and non-indexed geometry, `MeshBin` treats the refusal as "skip this
 *    material", and the result is not an exception — it is a bench with no top,
 *    or a speaker with no cabinet. The symptom is a *missing* mesh, which is
 *    only visible against an expectation. So this asserts that no asset comes
 *    back with fewer distinct materials than it asked the registry for.
 * 2. **A footprint stops matching its mesh.** `Plan` lays a room out from
 *    `footprintOf` without ever building the asset, so a footprint that
 *    under-reports is a prop halfway through a wall and a footprint that
 *    over-reports is a room that will not pack. Nothing checks the two against
 *    each other except this.
 *
 * The chamfer pass is what made the first of these urgent: turning a desktop
 * from `box()` into `roundedBoxOf()` changes the primitive class of the whole
 * `deskSurface` material, and getting that wrong on any of the five slabs it was
 * applied to would have shipped a desk with no top and thrown nothing.
 */

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 { OFFICE_ASSETS } from "../../assets/office/index.ts";
import { seeded, withStubCanvas } from "./fakeCanvas.ts";

/**
 * The registry's own count. Asserted rather than derived so that adding an asset
 * is a deliberate two-line change and removing one cannot happen by accident —
 * `index.ts`'s header quotes this number, and a header that quietly disagrees
 * with the code is worse than no header.
 */
const CATALOGUE_SIZE = 40;

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 disposeObject(root: THREE.Object3D): void {
  for (const mesh of meshes(root)) mesh.geometry.dispose();
}

/**
 * A registry that records which roles an asset actually asked for, so a dropped
 * material can be told apart from a material the builder never wanted.
 */
class CountingRegistry extends MaterialRegistry {
  readonly requested = new Set<string>();

  override get(role: Parameters<MaterialRegistry["get"]>[0]): ReturnType<MaterialRegistry["get"]> {
    const material = super.get(role);
    this.requested.add(material.name);
    return material;
  }

  override tinted(
    role: Parameters<MaterialRegistry["tinted"]>[0],
    color: number,
  ): ReturnType<MaterialRegistry["tinted"]> {
    const material = super.tinted(role, color);
    this.requested.add(material.name);
    return material;
  }

  override variant(
    role: Parameters<MaterialRegistry["variant"]>[0],
    index: number,
    color?: number,
  ): ReturnType<MaterialRegistry["variant"]> {
    const material = super.variant(role, index, color);
    this.requested.add(material.name);
    return material;
  }
}

describe("the office catalogue as a whole", () => {
  it("registers exactly the assets `index.ts` says it does", () => {
    assert.equal(OFFICE_ASSETS.length, CATALOGUE_SIZE);
    const ids = OFFICE_ASSETS.map((def) => def.id);
    assert.equal(new Set(ids).size, ids.length, "two assets share an id");
    for (const id of ids) {
      assert.equal(kit.has(id), true, `${id} is exported but not registered`);
      assert.match(id, /^tera:/, `${id} is not in the tera namespace`);
    }
  });

  it("keeps every material it asked for — nothing is silently dropped in the merge", () => {
    const restore = withStubCanvas();
    for (const def of OFFICE_ASSETS) {
      const materials = new CountingRegistry({ quality: "high" });
      try {
        const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
        const drawn = new Set(meshes(object).map((m) => (m.material as THREE.Material).name));
        const missing = [...materials.requested].filter((name) => !drawn.has(name));
        assert.deepEqual(
          missing,
          [],
          `${def.id} asked for ${missing.join(", ")} and drew nothing in it — a merge was refused, which means an indexed part and an extrusion ended up in the same material`,
        );
        disposeObject(object);
      } finally {
        materials.dispose();
      }
    }
    restore();
  });

  it("advertises a footprint that encloses its own mesh", () => {
    const materials = new MaterialRegistry({ quality: "low" });
    try {
      for (const def of OFFICE_ASSETS) {
        const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
        const box = new THREE.Box3().setFromObject(object);
        const size = box.getSize(new THREE.Vector3());
        const footprint = kit.footprintOf(def.id);
        // 20 mm of slack, which is a finger's width. `Plan` spaces rooms with
        // these numbers, so a prop that overhangs its own footprint by more than
        // that is a prop that ends up inside a wall.
        assert.ok(
          size.x <= footprint.width + 0.02,
          `${def.id} is ${size.x.toFixed(3)} wide against a stated ${footprint.width}`,
        );
        assert.ok(
          size.z <= footprint.depth + 0.02,
          `${def.id} is ${size.z.toFixed(3)} deep against a stated ${footprint.depth}`,
        );
        assert.ok(
          box.max.y <= footprint.height + 0.02,
          `${def.id} reaches ${box.max.y.toFixed(3)} against a stated ${footprint.height}`,
        );
        disposeObject(object);
      }
    } finally {
      materials.dispose();
    }
  });

  it("keeps the two ceiling fittings, and only those two, hanging below their origin", () => {
    // `light.pendant` and `light.troffer` are the library's only exceptions to
    // "origin on the floor" (`common.ts`): their datum is the mounting plane and
    // all their geometry is at `y ≤ 0`, so a pack writes `elevation: 2.9` and
    // gets a lamp at 2.9 m. Every other asset — the floor lamp and the softbox
    // included — stands on the floor, and an asset that quietly adopts the
    // ceiling convention would sink through it.
    const ceilingHung = new Set(["tera:light.pendant", "tera:light.troffer"]);
    const materials = new MaterialRegistry({ quality: "low" });
    try {
      for (const def of OFFICE_ASSETS) {
        const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
        const box = new THREE.Box3().setFromObject(object);
        if (ceilingHung.has(def.id)) {
          assert.ok(box.max.y <= 0.001, `${def.id} has geometry above its mounting plane`);
        } else {
          assert.ok(box.min.y >= -0.02, `${def.id} starts ${box.min.y} below the floor`);
        }
        disposeObject(object);
      }
    } finally {
      materials.dispose();
    }
  });

  it("builds no light source anywhere in the catalogue", () => {
    // CONTRACT.md §4: Atmosphere is the sole light owner. Sixteen fittings each
    // carrying a `PointLight` is both the wrong owner and, past about four
    // shadow-casting lights, the end of the frame budget.
    const materials = new MaterialRegistry({ quality: "low" });
    try {
      for (const def of OFFICE_ASSETS) {
        const object = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
        object.traverse((child) => {
          assert.equal(child instanceof THREE.Light, false, `${def.id} constructs a light`);
        });
        disposeObject(object);
      }
    } finally {
      materials.dispose();
    }
  });

  it("is deterministic for the same seed, across the whole catalogue", () => {
    const materials = new MaterialRegistry({ quality: "low" });
    try {
      for (const def of OFFICE_ASSETS) {
        const first = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
        const second = kit.build(def.id, createAssetContext({ materials, rand: seeded() }));
        const encode = (root: THREE.Object3D): string => {
          const box = new THREE.Box3().setFromObject(root);
          const counts = meshes(root)
            .map((m) => `${m.name}:${m.geometry.getAttribute("position").count}`)
            .join(",");
          return `${counts}|${[...box.min.toArray(), ...box.max.toArray()]
            .map((v) => v.toFixed(6))
            .join(",")}`;
        };
        assert.equal(encode(first), encode(second), `${def.id} is not deterministic`);
        disposeObject(first);
        disposeObject(second);
      }
    } finally {
      materials.dispose();
    }
  });
});
