/**
 * The material roles, and the two new texture channels they bind.
 *
 * Four roles land here that three other workstreams are waiting on —
 * `deviceShell`, `deviceMesh`, `deviceIndicator`, `screenContent` — plus the
 * `alphaMap` wiring that turns `foliage` from a flat green shard into a leaf.
 * The assertions are deliberately about *bindings* rather than about numbers:
 * a roughness value is a judgement and will be re-tuned, but a `screenContent`
 * that has stopped carrying an `emissiveMap` is a screen that has gone back to
 * being a lamp, and a `foliage` with no `alphaMap` is the worst-looking asset in
 * the product returning.
 *
 * The registry is fed a stub `TextureBin` throughout. `node --test` has no
 * canvas, so the real bin correctly returns `null` for everything (see its own
 * comment about the zero-config boot), and a null map cannot demonstrate that
 * the map was bound to the right slot. The stub hands back real
 * `THREE.Texture`s named after what was asked for, so every assertion below is
 * about the code in `MaterialRegistry.create`, which is the code under test.
 */

import assert from "node:assert/strict";
import test from "node:test";
import * as THREE from "three";

import { MaterialRegistry, type SurfaceRole } from "../../assets/materials.ts";
import {
  DEFAULT_INTERIOR_PALETTE,
  LIGHTNESS_HEADROOM,
  ROLE_SHIFTS,
  derivePalette,
} from "../../assets/palette.ts";
import {
  NORMAL_MAP_KINDS,
  SCREEN_UI_VARIANTS,
  TextureBin,
  type TextureKind,
} from "../../assets/textures.ts";

/** A bin that draws nothing but answers as though it had. */
class StubBin extends TextureBin {
  readonly asked: string[] = [];

  override get(kind: TextureKind, variant = 0): THREE.Texture | null {
    this.asked.push(`${kind}#${variant}`);
    const texture = new THREE.Texture();
    texture.name = `${kind}#${variant}`;
    return texture;
  }

  /**
   * Stubbed for the same reason `get` is, with one difference that matters: the
   * real `normal()` needs no canvas and would work here, but it would also build
   * six 512² fields per registry. What is under test is the *binding*, so the
   * stub keeps the one property an assertion can hang off — relief exists for
   * exactly the kinds that have a recipe and for no others.
   */
  override normal(kind: TextureKind): THREE.Texture | null {
    if (!NORMAL_MAP_KINDS.includes(kind)) return null;
    this.asked.push(`${kind}!normal`);
    const texture = new THREE.Texture();
    texture.name = `${kind}!normal`;
    return texture;
  }

  override variants(kind: TextureKind): number {
    return kind === "screenUI" ? SCREEN_UI_VARIANTS : 1;
  }
}

function registry(quality: "low" | "medium" | "high" = "high"): {
  materials: MaterialRegistry;
  bin: StubBin;
} {
  const bin = new StubBin(quality);
  return { materials: new MaterialRegistry({ quality, textures: bin }), bin };
}

const NEW_ROLES: SurfaceRole[] = ["deviceShell", "deviceMesh", "deviceIndicator", "screenContent"];

// ---- The published names ---------------------------------------------------

test("the registry exposes the four device roles", () => {
  const { materials } = registry();
  for (const role of NEW_ROLES) {
    const material = materials.get(role);
    assert.ok(material instanceof THREE.Material, `${role} did not resolve to a material`);
    assert.equal(material.name, role);
    // One material per role per registry — the sharing that is half the
    // draw-call budget.
    assert.equal(materials.get(role), material);
  }
});

test("every new role carries a palette derivation", () => {
  // The compiler enforces this for `ROLE_SHIFTS`, but not that the shift
  // actually produced a colour, and a role missing from the derived palette
  // would silently construct a material with `color: undefined` (black).
  for (const role of NEW_ROLES) {
    assert.ok(role in ROLE_SHIFTS, `${role} has no shift`);
    const color = DEFAULT_INTERIOR_PALETTE[role];
    assert.equal(typeof color, "number");
    assert.ok(color >= 0 && color <= 0xffffff);
  }
});

// ---- What each new role is -------------------------------------------------

test("the device shell and grille are metal, which they can now afford to be", () => {
  const { materials } = registry();
  const shell = materials.get("deviceShell") as THREE.MeshStandardMaterial;
  const mesh = materials.get("deviceMesh") as THREE.MeshStandardMaterial;

  assert.ok(shell.metalness > 0.15, "a device body with no metalness reads as painted plastic");
  assert.ok(mesh.metalness > 0.6, "a speaker grille is metal");
  // Double-sided: you see through a grille to the inside of the housing, and
  // that is most of what makes a speaker look like a speaker.
  assert.equal(mesh.side, THREE.DoubleSide);
  assert.equal(shell.side, THREE.FrontSide);
});

test("the indicator emits, and a tint carries into the emission", () => {
  const { materials } = registry();
  const led = materials.get("deviceIndicator") as THREE.MeshStandardMaterial;
  assert.equal(led.emissiveIntensity, 1, "an LED is a light source, not a lit surface");
  assert.notEqual(led.emissive.getHex(), 0x000000);

  // The device render layer tints this per state. That path must reach the
  // emissive term, or a powered mic and an unpowered one glow the same colour.
  const hot = materials.tinted("deviceIndicator", 0xff3a1e) as THREE.MeshStandardMaterial;
  assert.notEqual(hot, led);
  assert.equal(hot.color.getHex(THREE.SRGBColorSpace), 0xff3a1e);
  assert.equal(hot.emissive.getHex(THREE.SRGBColorSpace), 0xff3a1e);
  // Cached: a hundred indicators in three states are three materials.
  assert.equal(materials.tinted("deviceIndicator", 0xff3a1e), hot);
});

test("screen content is lit through its own map, not flat across the panel", () => {
  const { materials } = registry();
  const screen = materials.get("screenContent") as THREE.MeshStandardMaterial;

  assert.ok(screen.map, "screenContent must carry the drawn interface as its colour map");
  assert.equal(screen.map?.name, "screenUI#0");
  assert.ok(screen.emissiveMap, "screenContent must emit through the map, or it is a light box");
  assert.equal(screen.emissiveMap, screen.map, "the emissive map must be the same drawing");
  // White emissive, so the drawn colours are not tinted a second time on top of
  // `color` already tinting them.
  assert.equal(screen.emissive.getHex(), 0xffffff);
  assert.ok(screen.emissiveIntensity > 0.5);

  // And the palette gets out of the map's way: this is the one non-neutral
  // texture in the library, so a mid-grey role colour would multiply it to mud.
  const hsl = { h: 0, s: 0, l: 0 };
  new THREE.Color(DEFAULT_INTERIOR_PALETTE.screenContent).getHSL(hsl, THREE.SRGBColorSpace);
  assert.ok(hsl.l > 0.8, `screenContent is L=${hsl.l.toFixed(2)}, too dark to pass a drawing through`);
});

test("screen variants are separate cached materials, and variant 0 is the base", () => {
  const { materials } = registry();
  const base = materials.get("screenContent");

  assert.equal(materials.variant("screenContent", 0), base, "variant 0 must not mint a duplicate");

  const seen = new Set<THREE.Material>();
  for (let i = 0; i < SCREEN_UI_VARIANTS; i++) seen.add(materials.variant("screenContent", i));
  assert.equal(seen.size, SCREEN_UI_VARIANTS, "layouts collapsed onto the same material");

  // Cached, and wrapping rather than throwing: `furnish.ts` batches per kind, so
  // a caller hands this a running prop index and must not have to bound it.
  assert.equal(
    materials.variant("screenContent", 2),
    materials.variant("screenContent", 2 + SCREEN_UI_VARIANTS),
  );
  assert.equal(materials.variant("screenContent", -1), materials.variant("screenContent", SCREEN_UI_VARIANTS - 1));

  // A role with one layout ignores the index entirely.
  assert.equal(materials.variant("carpet", 3), materials.get("carpet"));
});

// ---- The alpha channel -----------------------------------------------------

test("foliage is a cutout, not a rectangle", () => {
  const { materials } = registry();
  const leaf = materials.get("foliage") as THREE.MeshStandardMaterial;

  assert.ok(leaf.alphaMap, "foliage without an alphaMap is the flat green shard on the live site");
  assert.equal(leaf.alphaMap?.name, "leafAlpha#0");
  assert.ok(leaf.alphaTest > 0.2 && leaf.alphaTest < 0.8, `alphaTest ${leaf.alphaTest} is at an edge of the range`);
  // Cutout, not blend: a leaf still writes depth, sorts as solid geometry and
  // casts a correctly-shaped shadow.
  assert.equal(leaf.transparent, false);
  assert.equal(leaf.depthWrite, true);
  assert.equal(leaf.side, THREE.DoubleSide);
});

test("a role with no alpha texture is not given an alphaTest", () => {
  const { materials } = registry();
  const carpet = materials.get("carpet") as THREE.MeshStandardMaterial;
  assert.equal(carpet.alphaMap, null);
  // A threshold with no map to test against compiles a branch into the shader
  // for a comparison that always passes.
  assert.equal(carpet.alphaTest, 0);
});

test("the leaf keeps its shape when it is ghosted", () => {
  const { materials } = registry();
  const ghost = materials.ghostOf("foliage");
  // The colour map is decoration and is dropped on purpose; the coverage map is
  // *shape*, and a ghost with no cutout is the shard again at 18% opacity.
  assert.equal(ghost.map, null);
  assert.ok(ghost.alphaMap, "ghostOf must not drop the coverage map with the colour map");
  assert.equal(ghost.transparent, true);
});

test("low quality still cuts the leaf out", () => {
  // `low` means no *shading* maps. An alpha cutout is one fetch and a discard,
  // and the alternative at low quality is not a cheaper plant, it is a shard.
  const { materials } = registry("low");
  const leaf = materials.get("foliage") as THREE.MeshLambertMaterial;
  assert.ok(leaf instanceof THREE.MeshLambertMaterial, "low quality must stay Lambert");
  assert.ok(leaf.alphaMap);
  assert.ok(leaf.alphaTest > 0);
});

// ---- The widened lightness band --------------------------------------------

test("the band was widened, and the darkest roles actually went darker", () => {
  assert.ok(LIGHTNESS_HEADROOM > 0.14, "the headroom was not relaxed");

  const palette = derivePalette();
  const hsl = { h: 0, s: 0, l: 0 };
  const lightnessOf = (role: SurfaceRole) => {
    new THREE.Color(palette[role]).getHSL(hsl, THREE.SRGBColorSpace);
    return hsl.l;
  };

  // Under the old 0.14 headroom the floor of the band was L≈0.248 and these
  // three were all clamped to it, which is why a screen bezel was a mid-grey.
  for (const role of ["screenBezel", "deviceShell", "deviceMesh"] as SurfaceRole[]) {
    const l = lightnessOf(role);
    assert.ok(l < 0.248, `${role} is L=${l.toFixed(3)}, still clamped up into mid-grey`);
    assert.ok(l > 0.05, `${role} is L=${l.toFixed(3)}, past charcoal into black`);
  }

  // Saturation gets no concession, and widening the lightness band must not
  // have quietly widened that too: every role stays inside the city's own
  // saturation range.
  const city = Object.values(palette).map((hex) => {
    new THREE.Color(hex).getHSL(hsl, THREE.SRGBColorSpace);
    return hsl.s;
  });
  assert.ok(Math.max(...city) <= 0.6, "an interior role has become more chromatic than the city");
});

// ---- Glass -----------------------------------------------------------------

/**
 * The one role that changes material *class* with quality.
 *
 * A 22%-opacity blend is a grey film. Transmission is glass: it refracts what
 * is behind it, keeps a specular highlight and an environment reflection on top,
 * and turns `roughness` into frosting instead of into a matte grey. What must
 * survive the change is the note at the role itself — glass writes no depth —
 * because three.js's own advice for a transmissive material is the opposite,
 * and an office is a box of glass boxes where the first sheet the sorter reaches
 * would erase the two behind it.
 */
test("glazing at medium and high is physical glass that still writes no depth", () => {
  for (const quality of ["medium", "high"] as const) {
    const { materials } = registry(quality);
    const glass = materials.get("glazing");
    assert.ok(
      glass instanceof THREE.MeshPhysicalMaterial,
      `glazing at ${quality} is ${glass.type}, not physical glass`,
    );
    const physical = glass as THREE.MeshPhysicalMaterial;
    assert.ok(physical.transmission > 0, "glazing has no transmission");
    assert.equal(physical.ior, 1.5, "glazing is no longer soda-lime glass");
    assert.ok(physical.thickness > 0, "glazing has no thickness to refract through");
    // The line the spec asked to be kept, kept.
    assert.equal(physical.depthWrite, false, "glass started writing depth");
    // Transmission carries the see-through; blending it as well would leave the
    // sheet four fifths invisible and refracting the fifth that was left.
    assert.equal(physical.transparent, false);
    assert.equal(physical.opacity, 1);
    // three.js scales transmission by `1 - metalness`, so any metalness at all
    // is that fraction of the glass quietly turned back into a mirror.
    assert.equal(physical.metalness, 0);
    assert.equal(physical.side, THREE.DoubleSide);
  }
});

test("glazing at low falls back to a blended sheet rather than to nothing", () => {
  const { materials } = registry("low");
  const glass = materials.get("glazing");
  // `low` is the integrated-GPU setting and a transmission pass is a full
  // render-target copy, so the blend has to stay reachable — and it is still a
  // window, so it still must not write depth.
  assert.ok(glass instanceof THREE.MeshLambertMaterial);
  assert.equal(glass.transparent, true);
  assert.ok(glass.opacity < 0.5);
  assert.equal(glass.depthWrite, false);
});

test("a ghosted sheet of glass stops refracting", () => {
  const { materials } = registry("high");
  const ghost = materials.ghostOf("glazing") as THREE.MeshPhysicalMaterial;
  // The occlusion fade is a hint and is rebuilt as the camera moves; putting it
  // through the transmission pass buys nothing and costs a target copy.
  assert.equal(ghost.transmission, 0);
  assert.equal(ghost.transparent, true);
  assert.equal(ghost.depthWrite, false);
});

// ---- The relief channel ----------------------------------------------------

test("every role with a texture also carries its relief", () => {
  const { materials } = registry("high");
  // Six kinds have relief; a whiteboard and a screen are flat. A role gets the
  // relief of its own texture or nothing — there is no third option, and no role
  // opts in separately.
  const expected: [SurfaceRole, boolean][] = [
    ["carpet", true],
    ["woodFloor", true],
    ["tile", true],
    ["ceilingTile", true],
    ["plaster", true],
    ["chairFabric", true],
    ["whiteboard", false],
    ["screenContent", false],
    ["metalTrim", false],
    ["deviceShell", false],
  ];
  for (const [role, hasRelief] of expected) {
    const material = materials.get(role) as THREE.MeshStandardMaterial;
    if (hasRelief) {
      assert.ok(material.normalMap, `${role} lost its normal map`);
      assert.match(material.normalMap.name, /!normal$/);
    } else {
      assert.equal(material.normalMap, null, `${role} gained relief it has no surface for`);
    }
  }
});

test("relief follows a tint and a variant, because it is the same surface", () => {
  const { materials } = registry("high");
  const tinted = materials.tinted("carpet", 0x884422) as THREE.MeshStandardMaterial;
  assert.ok(tinted.normalMap, "a recoloured carpet lost its pile");
  const variant = materials.variant("screenContent", 3) as THREE.MeshStandardMaterial;
  assert.equal(variant.normalMap, null, "a screen layout grew relief");
});

test("low quality binds no relief at all", () => {
  const { materials } = registry("low");
  const carpet = materials.get("carpet") as THREE.MeshLambertMaterial;
  // `low` exists to compile the cheap shader. A normal map is a fetch and a
  // matrix multiply per fragment, which is exactly the cost it is refusing.
  assert.ok(carpet instanceof THREE.MeshLambertMaterial);
  assert.equal(carpet.normalMap, null);
});

test("a ghost drops the relief with the colour, for the same reason", () => {
  const { materials } = registry("high");
  const ghost = materials.ghostOf("carpet") as THREE.MeshStandardMaterial;
  assert.equal(ghost.map, null);
  assert.equal(ghost.normalMap, null, "an 82%-transparent surface is still being bumped");
});
