/**
 * The two pieces of hardware the smart-device layer drives: a desk microphone
 * and a desk monitor speaker.
 *
 * These are not props with a light glued on. They are the *instruments* the
 * studio simulation observes and commands — a mic that can be muted and gained,
 * a speaker that can be turned up and played through — so they are modelled to
 * be looked at from the distance somebody sits from their own desk, which is
 * about sixty centimetres. At that range a speaker with a painted-on grille is a
 * lie you can see, which is why the grille here is slats with gaps between them
 * and the driver is a real cone in a real surround.
 *
 * ### The id convention is load-bearing
 *
 * `<namespace>:device.<kind>.<placement>` — `tera:device.mic.desk`,
 * `tera:device.speaker.desk`. `deviceKindOfAssetId()` in `src/devices/types.ts`
 * parses the kind straight back out of the id, and the device API refuses a
 * declaration whose asset kind disagrees with its declared kind. That is what
 * lets a self-hoster register `acme:device.mic.boom` and have it read as a
 * microphone for free, with no table to edit and no fork; and it is why anything
 * not matching the pattern is treated as "not device hardware" rather than as a
 * silently mis-typed device.
 *
 * ### Every device exposes a sub-object named `indicator`
 *
 * The device render layer (`src/interiors/devices.ts`) tints one part of each
 * device per state — powered, muted, idle — by swapping the material on it with
 * `materials.tinted("deviceIndicator", colour)`, which reaches both `color` and
 * `emissive`. It finds that part by **name**, not by material and not by index:
 * a name survives a self-hoster's override, a material does not (two devices
 * sharing `deviceIndicator` would both light up), and an index does not survive
 * anybody adding a part.
 *
 * So the contract is exactly: `object.getObjectByName("indicator")` returns a
 * group holding the LED and nothing else. It is a separate group rather than a
 * separate mesh because `MeshBin.build` names its meshes after their material,
 * and the layer above should not have to know what material an LED happens to
 * be made of.
 *
 * ### Roles, and the primitive-class rule
 *
 * `deviceShell` is the moulded housing, `deviceMesh` the perforated parts —
 * grille, basket, windscreen — and `deviceIndicator` the LED. `common.ts` warns
 * that every part under one material must be all-indexed or all-non-indexed or
 * `mergeGeometries` silently drops it; both assets here are built entirely from
 * indexed primitives (`box`, `cylinder`, `rod`, `cone`, `sphere`, `disc`), which
 * makes that rule impossible to break by accident rather than merely remembered.
 *
 * Neither takes a `colorKey`. A studio microphone is the colour a studio
 * microphone is, and the one part that changes colour changes it because of
 * *state*, which is the device layer's business and not a pack's.
 */

import * as THREE from "three";
import { defineAsset, type AssetContext, type AssetId } from "../kit.ts";
import { MeshBin } from "../parts.ts";

/** The built-in device ids, in the order `src/devices` expects to find them. */
export const DEVICE_ASSET_IDS: readonly AssetId[] = [
  "tera:device.mic.desk",
  "tera:device.speaker.desk",
];

/** The name the device render layer looks up to find the tintable LED. */
export const DEVICE_INDICATOR_NAME = "indicator";

/**
 * Assemble a device: its static hardware, plus the one sub-object that changes
 * colour, under the agreed name.
 *
 * The LED bin is built with shadows off in both directions. A four-millimetre
 * emissive dot has nothing meaningful to cast and nothing meaningful to receive,
 * and leaving it in the shadow pass costs a draw call in every cascade for a
 * part that is smaller than a shadow-map texel at any sensible resolution.
 */
function deviceGroup(name: string, hardware: MeshBin, led: MeshBin): THREE.Group {
  const group = new THREE.Group();
  group.name = name;
  group.add(hardware.build(name));
  const indicator = led.build(DEVICE_INDICATOR_NAME, {
    castShadow: false,
    receiveShadow: false,
  });
  indicator.name = DEVICE_INDICATOR_NAME;
  group.add(indicator);
  return group;
}

/**
 * A ring of `count` short bars around the Y axis at `radius`.
 *
 * Used for the shock mount's elastic suspension and for the speaker's driver
 * surround. Worth a helper rather than three copies because getting the local
 * yaw wrong produces a ring of bars that all face the same way, which reads as a
 * mistake rather than as a detail.
 */
function ringOfBars(
  bin: MeshBin,
  ctx: AssetContext,
  material: Parameters<MeshBin["add"]>[1],
  r: {
    count: number;
    radius: number;
    y: number;
    size: [number, number, number];
    pitch?: number;
    phase?: number;
  },
): void {
  for (let i = 0; i < r.count; i++) {
    const yaw = (i / r.count) * Math.PI * 2 + (r.phase ?? 0);
    bin.add(ctx.parts.box(), material, {
      x: Math.sin(yaw) * r.radius,
      z: Math.cos(yaw) * r.radius,
      y: r.y,
      size: r.size,
      yaw,
      pitch: r.pitch ?? 0,
    });
  }
}

// ---- Microphone -----------------------------------------------------------

type MicParams = {
  /** Length of the capsule body itself, metres. A large-diaphragm condenser. */
  bodyLength: number;
  /** Diameter of the weighted desk base. */
  baseDiameter: number;
  /** Floor of the base to the centre of the capsule. */
  standHeight: number;
  /** Radians the capsule leans back from vertical, toward the person at +Z. */
  tilt: number;
};

/**
 * A shock-mounted desk condenser.
 *
 * Four things carry it, in the order they matter at desk distance:
 *
 * 1. **The shock mount.** A microphone hanging inside a ring on visible elastic
 *    is the single most recognisable thing about a studio desk, and it is the
 *    part that says "this is a real microphone" before any of the rest resolves.
 * 2. **The basket.** A grille with gaps in it, not a painted cylinder — eight
 *    vertical wires and two hoops, in the double-sided `deviceMesh` role, so you
 *    see through it to the shaded inside of the head at a glancing angle.
 * 3. **The windscreen**, as a foam sphere pushed over the top of the basket. It
 *    is what breaks the hard cylinder silhouette.
 * 4. **The mute LED**, on the front of the body under the basket, where the
 *    hardware button is on almost every one of these.
 *
 * Faces −Z at yaw zero like everything else, which puts the front of the capsule
 * and the LED at +Z, toward the person (`common.ts`).
 */
export const deviceMicDesk = defineAsset<MicParams>({
  id: "tera:device.mic.desk",
  label: "Desk microphone",
  defaults: { bodyLength: 0.09, baseDiameter: 0.12, standHeight: 0.21, tilt: 0.16 },

  footprint(p) {
    // The shock mount is wider than the base and the capsule leans out of it, so
    // the footprint is the ring's swept width rather than the base's diameter.
    // 1.5 × the body, measured off the built mesh rather than guessed: the ring
    // is `bodyLength × 0.66` in radius and the suspension bars stick out past it,
    // and the foam windscreen is wider again than the basket it is pushed over.
    const ring = p.bodyLength * 1.5;
    const height = p.standHeight + p.bodyLength * 0.95 + 0.05;
    return { width: Math.max(p.baseDiameter, ring), depth: ring + 0.04, height, clearance: 0.12 };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const led = new MeshBin();
    const shell = ctx.materials.get("deviceShell");
    const grille = ctx.materials.get("deviceMesh");
    const metal = ctx.materials.get("metalTrim");

    // ---- The weighted base. Two discs and a fillet: a microphone base is heavy
    // and low, and a straight cylinder reads as a cotton reel.
    bin.add(P.cylinder(20), shell, { size: [p.baseDiameter, 0.014, p.baseDiameter] });
    bin.add(P.cylinder(20), shell, {
      y: 0.014,
      size: [p.baseDiameter * 0.82, 0.012, p.baseDiameter * 0.82],
    });
    bin.add(P.cylinder(16), metal, {
      y: 0.026,
      size: [p.baseDiameter * 0.34, 0.01, p.baseDiameter * 0.34],
    });

    // ---- The post, and the yoke that carries the ring.
    const ringY = p.standHeight;
    bin.add(P.rod(), metal, { y: 0.03, size: [0.014, ringY - 0.03 - 0.01, 0.014] });
    const ringR = p.bodyLength * 0.66;
    for (const sx of [-1, 1]) {
      bin.add(P.box(), metal, {
        x: sx * ringR * 0.7,
        y: ringY - 0.055,
        size: [0.008, 0.075, 0.008],
        roll: sx * 0.32,
      });
    }

    // ---- The shock-mount ring. Twelve short bars around the circle rather than
    // a torus: `parts.ts` has no torus, and twelve boxes is both cheaper and, at
    // this size, indistinguishable from one.
    ringOfBars(bin, ctx, metal, {
      count: 12,
      radius: ringR,
      y: ringY - 0.006,
      size: [ringR * 0.58, 0.012, 0.009],
      pitch: 0,
    });
    // The elastic. Six lines from the ring in to the body, alternating up and
    // down the capsule the way a real suspension is strung.
    for (let i = 0; i < 6; i++) {
      const yaw = (i / 6) * Math.PI * 2 + 0.26;
      const lift = i % 2 === 0 ? 0.022 : -0.022;
      bin.add(P.box(), metal, {
        x: (Math.sin(yaw) * ringR) / 2,
        z: (Math.cos(yaw) * ringR) / 2,
        y: ringY + lift * 0.5,
        size: [ringR, 0.004, 0.004],
        yaw: yaw + Math.PI / 2,
        roll: lift > 0 ? 0.42 : -0.42,
      });
    }

    // ---- The capsule. Built about the ring's centre and tilted back, so the
    // whole head — body, basket, windscreen, LED — leans as one piece.
    const tilt = p.tilt;
    const dia = p.bodyLength * 0.54;
    const lean = (d: number): { y: number; z: number } => ({
      y: Math.cos(tilt) * d,
      z: Math.sin(tilt) * d,
    });

    const bodyBase = lean(-p.bodyLength * 0.5);
    bin.add(P.cylinder(18), shell, {
      y: ringY + bodyBase.y,
      z: bodyBase.z,
      size: [dia, p.bodyLength * 0.62, dia],
      pitch: -tilt,
    });
    // A collar where the body meets the head. Every one of these has one and it
    // is what stops the capsule reading as a single extruded tube.
    const collar = lean(p.bodyLength * 0.1);
    bin.add(P.cylinder(18), metal, {
      y: ringY + collar.y,
      z: collar.z,
      size: [dia * 1.08, 0.006, dia * 1.08],
      pitch: -tilt,
    });

    // ---- The basket: two hoops and eight wires, with a dome on top.
    const headBase = lean(p.bodyLength * 0.12);
    const headLength = p.bodyLength * 0.5;
    const headDia = dia * 1.12;
    for (const at of [0.12, 0.62]) {
      const hoop = lean(p.bodyLength * (0.12 + at * 0.5));
      bin.add(P.cylinder(18), metal, {
        y: ringY + hoop.y,
        z: hoop.z,
        size: [headDia * 1.02, 0.004, headDia * 1.02],
        pitch: -tilt,
      });
    }
    for (let i = 0; i < 8; i++) {
      const yaw = (i / 8) * Math.PI * 2;
      // The wires stand in the head's own tilted frame, so they are placed at
      // the head's base and rotated with it rather than around the world Y.
      const offset = headDia * 0.5;
      bin.add(P.box(), grille, {
        x: Math.sin(yaw) * offset,
        y: ringY + headBase.y + Math.cos(yaw) * offset * Math.sin(tilt),
        z: headBase.z + Math.cos(yaw) * offset * Math.cos(tilt),
        size: [0.0035, headLength, 0.0035],
        pitch: -tilt,
      });
    }
    const domeAt = lean(p.bodyLength * 0.12 + headLength);
    bin.add(P.sphere(14), grille, {
      y: ringY + domeAt.y - headDia * 0.25,
      z: domeAt.z,
      size: [headDia, headDia * 0.5, headDia],
      pitch: -tilt,
    });

    // ---- The foam windscreen, pushed over the basket. Slightly bigger than the
    // head and slightly squashed, because foam is.
    const foamAt = lean(p.bodyLength * 0.3);
    bin.add(P.sphere(16), grille, {
      y: ringY + foamAt.y - headDia * 0.62,
      z: foamAt.z,
      size: [headDia * 1.24, headDia * 1.34, headDia * 1.24],
      pitch: -tilt,
    });

    // ---- The mute LED, on the front of the body below the basket.
    const ledAt = lean(-p.bodyLength * 0.12);
    led.add(P.cylinder(10), ctx.materials.get("deviceIndicator"), {
      y: ringY + ledAt.y,
      z: ledAt.z + dia * 0.5,
      size: [0.008, 0.003, 0.008],
      pitch: Math.PI / 2 - tilt,
    });

    return deviceGroup("device.mic.desk", bin, led);
  },
});

// ---- Speaker --------------------------------------------------------------

type SpeakerParams = {
  width: number;
  height: number;
  depth: number;
  /** Slats across the grille. Fewer reads as a radiator, more as a solid panel. */
  slats: number;
};

/**
 * A compact powered monitor speaker.
 *
 * The grille is the reason this asset exists in geometry rather than in a
 * texture. `deviceMesh` is double-sided (`materials.ts`) precisely so that the
 * gaps between the slats show the shaded inside of the cabinet behind them, and
 * that parallax — slats in front, driver behind, dark cabinet behind that — is
 * the entire difference between a speaker and a box with stripes on it. It costs
 * a dozen boxes.
 *
 * The bass port is a real hole in the same sense: a recessed dark tube on the
 * front baffle rather than a black circle drawn on it.
 *
 * The cabinet is `roundedBoxOf` at its finished size, so the 4 mm chamfer round
 * its edges is genuinely circular instead of an ellipse stretched by a
 * non-uniform scale — which is the whole reason that method exists.
 */
export const deviceSpeakerDesk = defineAsset<SpeakerParams>({
  id: "tera:device.speaker.desk",
  label: "Desk monitor speaker",
  defaults: { width: 0.14, height: 0.22, depth: 0.17, slats: 11 },

  footprint(p) {
    // The isolation pad under it is a touch wider than the cabinet, and the
    // grille stands a few millimetres proud of the baffle at +Z.
    return {
      width: p.width + 0.012,
      depth: p.depth + 0.018,
      height: p.height + 0.016,
      clearance: 0.1,
    };
  },

  build(p, ctx) {
    const P = ctx.parts;
    const bin = new MeshBin();
    const led = new MeshBin();
    const shell = ctx.materials.get("deviceShell");
    const mesh = ctx.materials.get("deviceMesh");
    const metal = ctx.materials.get("metalTrim");

    // ---- The isolation pad. A monitor on a desk stands on foam, and the 16 mm
    // of shadow under the cabinet is what stops it looking stuck to the desktop.
    const pad = 0.014;
    bin.add(P.box(), ctx.materials.get("upholstery"), {
      y: 0,
      size: [p.width + 0.012, pad, p.depth + 0.006],
      pitch: 0,
    });

    // ---- The cabinet, and the baffle recessed into its front face.
    bin.add(P.roundedBoxOf(p.width, p.height, p.depth, 0.005), shell, { y: pad, size: 1 });
    const baffleZ = p.depth / 2 - 0.008;
    bin.add(P.roundedBoxOf(p.width - 0.014, p.height - 0.014, 0.01, 0.004), shell, {
      y: pad + 0.007,
      z: baffleZ - 0.004,
      size: 1,
    });

    // ---- The woofer: surround ring, cone, dust cap. The cone points at the
    // person, so it is a `cone()` rolled over — its base is its wide end.
    const wooferY = pad + p.height * 0.36;
    const wooferR = Math.min(p.width * 0.36, p.height * 0.24);
    bin.add(P.cylinder(20), metal, {
      y: wooferY,
      z: baffleZ,
      size: [wooferR * 2.2, 0.006, wooferR * 2.2],
      pitch: Math.PI / 2,
    });
    bin.add(P.cone(20), mesh, {
      y: wooferY,
      z: baffleZ - 0.024,
      size: [wooferR * 1.9, 0.026, wooferR * 1.9],
      pitch: -Math.PI / 2,
    });
    // The dust cap rides on `metalTrim` rather than on `deviceShell`: the shell
    // is all `roundedBoxOf` in this asset and a sphere is indexed, and mixing the
    // two under one material makes `mergeGeometries` drop the whole cabinet.
    bin.add(P.sphere(12), metal, {
      y: wooferY,
      z: baffleZ - 0.004,
      size: [wooferR * 0.7, wooferR * 0.5, wooferR * 0.7],
      pitch: -Math.PI / 2,
    });

    // ---- The tweeter, in its own shallow waveguide.
    const tweeterY = pad + p.height * 0.78;
    const tweeterR = wooferR * 0.42;
    bin.add(P.cylinder(16), metal, {
      y: tweeterY,
      z: baffleZ,
      size: [tweeterR * 2.6, 0.005, tweeterR * 2.6],
      pitch: Math.PI / 2,
    });
    bin.add(P.sphere(12), mesh, {
      y: tweeterY,
      z: baffleZ - 0.002,
      size: [tweeterR * 1.6, tweeterR * 1.2, tweeterR * 1.6],
      pitch: -Math.PI / 2,
    });

    // ---- The bass port: a tube sunk into the baffle between the drivers.
    const portY = pad + p.height * 0.16;
    bin.add(P.cylinder(14), mesh, {
      y: portY,
      z: baffleZ - 0.03,
      size: [wooferR * 0.62, 0.034, wooferR * 0.62],
      pitch: Math.PI / 2,
    });

    // ---- The grille. Horizontal slats standing proud of the baffle, with real
    // gaps between them — the point of the whole asset.
    const slats = Math.max(3, Math.round(p.slats));
    const span = p.height - 0.03;
    const pitch = span / slats;
    for (let i = 0; i < slats; i++) {
      bin.add(P.box(), mesh, {
        y: pad + 0.015 + i * pitch,
        z: baffleZ + 0.004,
        size: [p.width - 0.018, pitch * 0.52, 0.004],
      });
    }
    // Two uprights holding the slats, so the grille is a frame and not a stack.
    for (const sx of [-1, 1]) {
      bin.add(P.box(), mesh, {
        x: (sx * (p.width - 0.018)) / 2,
        y: pad + 0.015,
        z: baffleZ + 0.004,
        size: [0.005, span, 0.005],
      });
    }

    // ---- The power LED, bottom-centre under the grille.
    led.add(P.cylinder(10), ctx.materials.get("deviceIndicator"), {
      y: pad + 0.008,
      z: baffleZ + 0.006,
      size: [0.006, 0.003, 0.006],
      pitch: Math.PI / 2,
    });

    return deviceGroup("device.speaker.desk", bin, led);
  },
});

/** Both device assets, for `index.ts` and for anything registering a subset. */
export const DEVICE_ASSETS = [deviceMicDesk, deviceSpeakerDesk] as const;
