/**
 * The studio kit: twelve new kinds, and the properties that make them useful to
 * a pack rather than merely present in a registry.
 *
 * The interesting assertion in here is the *count*. `furnish.ts` batches props
 * per kind and every instance of a kind is geometrically identical, so a pack
 * cannot buy apparent density by placing more props — only by using more kinds.
 * "At least eight new kinds" is therefore a real acceptance number and not a
 * round one, and pinning it stops a later tidy-up quietly merging two assets
 * into one parameterised asset and taking a visible amount of the LA studio's
 * variety with it.
 *
 * Everything else here is the same three failure modes every asset file has:
 * a builder that throws at one quality level and not the other, a footprint that
 * does not enclose its own mesh, and a material that `mergeGeometries` refused
 * because somebody mixed an extrusion in with the boxes.
 */

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

import { createAssetContext, kit, type AssetContext } from "../../assets/kit.ts";
import { MaterialRegistry, type MaterialQuality } from "../../assets/materials.ts";
import { OFFICE_ASSETS, STUDIO_ASSET_IDS } from "../../assets/office/index.ts";
import { seeded, withStubCanvas } from "./fakeCanvas.ts";

/** The ids that existed before the studio kit landed, from the same catalogue. */
const PRE_STUDIO_IDS = new Set(
  OFFICE_ASSETS.map((def) => def.id).filter((id) => !STUDIO_ASSET_IDS.includes(id)),
);

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();
}

function context(quality: MaterialQuality): { ctx: AssetContext; done: () => void } {
  const restore = withStubCanvas();
  const materials = new MaterialRegistry({ quality });
  return {
    ctx: createAssetContext({ materials, rand: seeded() }),
    done: () => {
      materials.dispose();
      restore();
    },
  };
}

describe("studio asset kit", () => {
  it("adds at least eight kinds that did not exist before, because only kinds add density", () => {
    assert.ok(
      STUDIO_ASSET_IDS.length >= 8,
      `only ${STUDIO_ASSET_IDS.length} studio kinds; furnish.ts batches per kind, so fewer than eight is not a visible change`,
    );
    for (const id of STUDIO_ASSET_IDS) {
      assert.equal(kit.has(id), true, `${id} is not registered`);
      assert.equal(PRE_STUDIO_IDS.has(id), false, `${id} collides with an existing asset id`);
      assert.match(id, /^tera:[a-z-]+\.[a-z-]+$/, `${id} is not a namespaced asset id`);
    }
    assert.equal(new Set(STUDIO_ASSET_IDS).size, STUDIO_ASSET_IDS.length, "duplicate studio id");
  });

  for (const quality of ["low", "high"] as const) {
    it(`builds every studio kind without throwing at \`${quality}\` quality`, () => {
      const { ctx, done } = context(quality);
      try {
        for (const id of STUDIO_ASSET_IDS) {
          const object = kit.build(id, ctx);
          assert.equal(object.userData.assetId, id);
          assert.notEqual(
            object.userData.missing,
            true,
            `${id} fell through to the placeholder box`,
          );
          const built = meshes(object);
          assert.ok(built.length > 0, `${id} built no geometry`);
          const box = new THREE.Box3().setFromObject(object);
          assert.ok(
            [...box.min.toArray(), ...box.max.toArray()].every(Number.isFinite),
            `${id} has non-finite bounds`,
          );
          assert.ok(box.max.y > 0.02, `${id} has no visible height`);
          disposeObject(object);
        }
      } finally {
        done();
      }
    });
  }

  it("produces identical geometry at every quality level", () => {
    // Quality changes *materials*, never meshes. If a builder ever branches on
    // `ctx.quality` the office stops being the same office on a weak GPU, and
    // the collision segments a pack derives from a footprint stop matching what
    // is drawn.
    const counts: Record<string, number[]> = {};
    for (const quality of ["low", "high"] as const) {
      const { ctx, done } = context(quality);
      try {
        for (const id of STUDIO_ASSET_IDS) {
          const object = kit.build(id, ctx);
          let triangles = 0;
          for (const mesh of meshes(object)) {
            const index = mesh.geometry.getIndex();
            triangles += index
              ? index.count / 3
              : mesh.geometry.getAttribute("position").count / 3;
          }
          (counts[id] ??= []).push(triangles);
          disposeObject(object);
        }
      } finally {
        done();
      }
    }
    for (const [id, [low, high]] of Object.entries(counts)) {
      assert.equal(low, high, `${id} builds different geometry at low and high quality`);
    }
  });

  it("encloses its own geometry in the footprint layout is given", () => {
    const { ctx, done } = context("low");
    try {
      for (const id of STUDIO_ASSET_IDS) {
        const object = kit.build(id, ctx);
        const box = new THREE.Box3().setFromObject(object);
        const size = box.getSize(new THREE.Vector3());
        const footprint = kit.footprintOf(id);
        // 20 mm of slack. A footprint is a layout number rather than a
        // measurement, but `Plan` spaces rooms with it and a prop that overhangs
        // its own footprint by more than a finger's width ends up in a wall.
        assert.ok(size.x <= footprint.width + 0.02, `${id} is ${size.x} wide vs ${footprint.width}`);
        assert.ok(size.z <= footprint.depth + 0.02, `${id} is ${size.z} deep vs ${footprint.depth}`);
        assert.ok(
          box.max.y <= footprint.height + 0.02,
          `${id} reaches ${box.max.y} vs ${footprint.height}`,
        );
        assert.ok(box.min.y >= -0.02, `${id} starts below the floor at ${box.min.y}`);
        disposeObject(object);
      }
    } finally {
      done();
    }
  });

  it("keeps the softbox recognisable to furnish.ts as a light fitting", () => {
    // `furnish.ts` decides what a luminaire is from the `:light.` in its id and
    // separates the glowing part from the housing by `emissiveIntensity > 0`.
    // Both halves have to be true or a softbox is a lump of metal that never
    // comes on, and neither is visible from inside this file.
    assert.ok(STUDIO_ASSET_IDS.includes("tera:light.softbox"));
    assert.equal(kit.resolveId("tera:light.softbox").includes(":light."), true);

    const { ctx, done } = context("high");
    try {
      const object = kit.build("tera:light.softbox", ctx);
      const emissive = meshes(object).filter((mesh) => {
        const material = mesh.material as THREE.MeshStandardMaterial;
        return (material.emissiveIntensity ?? 0) > 0;
      });
      assert.equal(emissive.length, 1, "a softbox has exactly one diffuser");
      assert.equal(emissive[0]?.castShadow, false, "the thing the light comes out of must not shadow the room");
      disposeObject(object);
    } finally {
      done();
    }
  });

  it("does not add a light source of its own — Atmosphere owns every light", () => {
    // CONTRACT.md §4. A hundred fittings each carrying a PointLight is both the
    // wrong owner and the end of the frame budget, and the softbox is exactly
    // the asset somebody would be tempted to give a real light to.
    const { ctx, done } = context("high");
    try {
      for (const id of STUDIO_ASSET_IDS) {
        const object = kit.build(id, ctx);
        object.traverse((child) => {
          assert.equal(
            child instanceof THREE.Light,
            false,
            `${id} constructs a light, which only Atmosphere may do`,
          );
        });
        disposeObject(object);
      }
    } finally {
      done();
    }
  });

  it("draws its foliage through the same cutout the greenery assets use", () => {
    // The trough is the one studio asset with leaves on it, and it exists partly
    // to keep the courtyard from being paving. If it ever stops sharing
    // `leafBlade`/`foliage` it goes straight back to the green-shard look that
    // this whole pass was written to remove.
    const { ctx, done } = context("high");
    try {
      const object = kit.build("tera:planter.trough", ctx);
      const foliage = meshes(object).find(
        (mesh) => (mesh.material as THREE.Material).name === "foliage",
      );
      assert.ok(foliage, "the planted trough has no foliage on it");
      const material = foliage.material as THREE.MeshStandardMaterial;
      assert.ok(material.alphaMap, "the trough's leaves are not cut out");
      assert.equal(material.alphaTest, 0.5);
      disposeObject(object);
    } finally {
      done();
    }
  });
});
