/**
 * The hardware, in the room.
 *
 * One microphone-sized object per authored `DeviceDeclaration`, standing on the
 * prop that declaration named, with a lamp on it that changes colour when the
 * state changes. That is the whole layer.
 *
 * ### It resolves nothing
 *
 * Every coordinate comes from `Plan.device()`, which has already turned the
 * authored anchor — a prop id and an offset in that prop's frame — into a
 * position and a yaw. This file does not repeat that arithmetic, does not parse
 * an asset id and does not decide whether a declaration is valid;
 * `validateDeviceDeclaration` and `Plan` did all three. A second derivation
 * here would be a second answer to "where is the mic", and the two would
 * disagree the first time somebody nudged the desk — which is precisely what
 * `DeviceAnchor` is shaped to prevent, and it would be this file undoing it.
 *
 * A declaration the plan dropped is therefore skipped rather than placed from
 * some other source. The plan drops one when its anchor prop is not there — a
 * typo, or a private prop in a public build — and inventing a position would
 * leave a microphone standing on the lobby floor.
 *
 * ### Nothing here is a light source
 *
 * `src/interiors/luminaires.ts` opens with that sentence and it is repeated
 * here because the temptation is stronger, not weaker: an LED is *obviously* a
 * light, and a `PointLight` per device would be one line. CONTRACT.md §4 says
 * Atmosphere is the sole light owner, and past about four shadow casters a
 * frame budget ends.
 *
 * The indicator reads as lit without one, through the `deviceIndicator`
 * material role: `materials.tinted("deviceIndicator", colour)` reaches both
 * `color` and `emissive`, and the role carries `emissiveIntensity: 1.0`, so
 * under the tone curve it reads as a lamp rather than as a white dot. A 3 mm
 * LED also implies **no house light at all** — unlike a ceiling fitting, which
 * is why `luminaires.ts` hands a scalar to the rig and this file has nothing to
 * hand anybody. If a device is ever added that genuinely lights a room, the
 * scalar goes to Atmosphere and the light still does not get constructed here.
 *
 * ### Materials are borrowed; geometry is owned
 *
 * Every mesh comes out of `MeshBin`, which clones and merges, so the geometry
 * in this subtree belongs to this layer and is disposed with it. The materials
 * come from the shared `MaterialRegistry` and are cached there across the whole
 * office — three states across a dozen devices is three materials, not
 * thirty-six — so `dispose()` deliberately does **not** touch them. Disposing a
 * registry material here would empty the desks in the rest of the building.
 */

import * as THREE from "three";
import { createAssetContext, type AssetRegistry } from "../assets/kit.ts";
import type { MaterialRegistry } from "../assets/materials.ts";
import { DEVICE_RANGES, type DeviceDeclaration, type DeviceState } from "../devices/types.ts";
import type { Plan } from "./plan.ts";

/**
 * The sub-object every device asset is expected to expose.
 *
 * A name rather than a `userData` flag because it is what an asset author
 * already writes — `MeshBin.build(name)` names the group — and because a name
 * survives the merge that turns an asset into one mesh per material. An asset
 * without one still builds and still stands on the desk; it simply has no lamp
 * to change, which is the right degrade for a self-hoster's own hardware model.
 */
const INDICATOR = "indicator";

/**
 * What each state looks like, as a colour.
 *
 * Four states and no more, deliberately: an indicator that encodes a continuous
 * reading is a display, and a display needs a legend. These are the four a
 * person can read across a room without one — dark, live, muted, idle — and the
 * numbers behind them belong in the panel, which has the room to say what they
 * mean.
 */
const INDICATOR_COLORS = {
  /** Powered off. Not black: an unlit LED is grey plastic, and black reads as a hole. */
  off: 0x2b3138,
  /** A microphone that is open, or a speaker that is playing. */
  live: 0x46d17a,
  /** A microphone that is muted. The one state worth reading from the doorway. */
  muted: 0xe2543f,
  /** Powered, idle: a speaker that is on with nothing playing. */
  idle: 0xd7a63c,
} as const;

/**
 * How much the indicator grows at full scale, as a fraction of its own size.
 *
 * The only reading this layer draws, and it is deliberately tiny. A meter
 * belongs in the panel; what the room needs is a hint that the thing is doing
 * something, which is what a lamp that breathes with the programme gives you.
 * It is a transform on one small object, so it costs nothing and it mints no
 * material — a level-driven *colour* would mint one per tenth of a decibel,
 * which is the version of this idea that must not be written.
 */
const INDICATOR_LEVEL_GAIN = 0.3;

export interface DeviceLayer {
  object: THREE.Object3D;
  /**
   * Show these readings. Ids this layer does not carry are ignored, and a
   * device this layer carries that is absent from `states` is left as it was —
   * a poll that dropped one device is not the same event as that device being
   * switched off, and only one of them should change what is on screen.
   */
  apply(states: readonly DeviceState[]): void;
  dispose(): void;
}

export interface DeviceLayerOptions {
  plan: Plan;
  declarations: readonly DeviceDeclaration[];
  assets: AssetRegistry;
  materials: MaterialRegistry;
}

interface Mounted {
  id: string;
  /** The asset's own indicator, or `null` for hardware that exposes none. */
  indicator: THREE.Object3D | null;
  /** The indicator's authored scale, so the level response is relative to it. */
  baseScale: number;
}

export function createDeviceLayer(options: DeviceLayerOptions): DeviceLayer {
  const { plan, declarations, assets, materials } = options;
  const object = new THREE.Group();
  object.name = "devices";
  const mounted = new Map<string, Mounted>();
  const warned = new Set<string>();

  for (const declaration of declarations) {
    const resolved = plan.device(declaration.id);
    if (resolved === null) {
      warnOnce(
        warned,
        `device ${declaration.id} is not in this plan — check that its anchor prop exists on ` +
          `level ${declaration.anchor.levelId} and survived this build's depth`,
      );
      continue;
    }

    // The transform, whole, from the plan. The offset is already folded into
    // `position` there, in the anchor prop's frame; re-applying it here would
    // place the mic twice as far up the desk as the pack asked for.
    const mount = new THREE.Group();
    mount.name = `device:${declaration.id}`;
    mount.position.set(resolved.position.x, resolved.position.y, resolved.position.z);
    mount.rotation.y = resolved.rotation;

    const hardware = assets.build(declaration.assetId, contextFor(declaration, materials, assets));
    mount.add(hardware);
    object.add(mount);

    const indicator = hardware.getObjectByName(INDICATOR) ?? null;
    if (indicator === null) {
      warnOnce(warned, `device asset ${declaration.assetId} exposes no "${INDICATOR}" sub-object; its state will not be visible in the room`);
    }
    mounted.set(declaration.id, {
      id: declaration.id,
      indicator,
      baseScale: indicator?.scale.x ?? 1,
    });
  }

  return {
    object,

    apply(states: readonly DeviceState[]): void {
      for (const state of states) {
        const device = mounted.get(state.id);
        if (device === undefined || device.indicator === null) continue;
        paint(device.indicator, materials, colorFor(state));
        device.indicator.scale.setScalar(device.baseScale * (1 + INDICATOR_LEVEL_GAIN * levelOf(state)));
      }
    },

    dispose(): void {
      object.traverse((child) => {
        const mesh = child as THREE.Mesh;
        // Geometry only. See the header: every material here belongs to the
        // shared registry and is still holding up the rest of the office.
        if (mesh.isMesh) mesh.geometry.dispose();
      });
      object.clear();
      mounted.clear();
    },
  };
}

/**
 * Which lamp, for one reading.
 *
 * Kind-aware rather than capability-aware, and that is the one place in this
 * whole surface where switching on the kind is right: this is a *picture* of a
 * device, and what a green light means on a microphone ("open") is not what it
 * means on a speaker ("playing"). Everywhere a control or an observation is
 * built, the capability list is the thing to iterate.
 */
function colorFor(state: DeviceState): number {
  if (!state.powered) return INDICATOR_COLORS.off;
  if (state.kind === "mic") return state.muted === true ? INDICATOR_COLORS.muted : INDICATOR_COLORS.live;
  return state.playing === true ? INDICATOR_COLORS.live : INDICATOR_COLORS.idle;
}

/**
 * The reading as 0..1, or zero for a device that reports no level.
 *
 * `undefined` is not zero — it means this device has no meter — but both come
 * out here as "do not grow the lamp", which is the honest picture for a device
 * that is not reporting anything to grow it by.
 */
function levelOf(state: DeviceState): number {
  if (state.levelDb === undefined || !state.powered) return 0;
  const { min, max } = DEVICE_RANGES.level;
  return Math.min(1, Math.max(0, (state.levelDb - min) / (max - min)));
}

/**
 * Point every mesh under the indicator at the tinted material for a state.
 *
 * `tinted` is cached by the registry, so the whole building's microphones share
 * one material per state and the assignment is a pointer write rather than a
 * new draw call. It reaches `color` and `emissive` together, which is what
 * makes a tinted LED read as lit instead of as a coloured pebble.
 */
function paint(indicator: THREE.Object3D, materials: MaterialRegistry, color: number): void {
  const material = materials.tinted("deviceIndicator", color);
  indicator.traverse((child) => {
    const mesh = child as THREE.Mesh;
    if (mesh.isMesh) mesh.material = material;
  });
}

/**
 * An asset context for one device.
 *
 * The random stream is seeded from the device id rather than from `Math.random`
 * so that the same pack builds the same hardware on every machine and in every
 * capture — the determinism rule every asset in this repo is held to. A device
 * is one small object built once, so the generator is three lines rather than a
 * dependency.
 */
function contextFor(
  declaration: DeviceDeclaration,
  materials: MaterialRegistry,
  assets: AssetRegistry,
) {
  let seed = 0x811c9dc5;
  for (let i = 0; i < declaration.id.length; i += 1) {
    seed ^= declaration.id.charCodeAt(i);
    seed = Math.imul(seed, 0x01000193);
  }
  const rand = (): number => {
    seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0;
    return seed / 4_294_967_296;
  };
  return createAssetContext({ materials, registry: assets, rand });
}

/**
 * One line per problem, once.
 *
 * A pack with a typo in a device id is a pack that repeats it — the same
 * declaration is rebuilt every time the office is entered — and a warning per
 * entry is a console nobody reads. `AssetRegistry.placeholder` warns once per
 * id for the same reason.
 */
function warnOnce(seen: Set<string>, message: string): void {
  if (seen.has(message)) return;
  seen.add(message);
  console.warn(`[tera/devices] ${message}`);
}
