/**
 * What a device *is*, before anything simulates one, draws one or commands one.
 *
 * This file is deliberately the first one written in this directory and it
 * exports no behaviour beyond small pure helpers, because four separate pieces
 * of the build are held up on the same four questions: what does a pack author
 * write into a room, what does a live reading look like, what can be commanded,
 * and how does a consumer know which of those apply to a given device. Packs
 * authors declarations from this; the device panel builds its controls from it;
 * the arena builds part of its observation vector from it; the API validates
 * against it. It therefore imports nothing — no THREE, no interiors, no wire —
 * and it can be imported by all of them.
 *
 * ### The split that matters most
 *
 * **A declaration is authored and lives in the office pack. A state never
 * does.** That is the same line `Presence` draws in `interiors/types.ts`, for
 * the same reason and with the same consequence: a pack is bundled into the
 * static build, so everything in it is public by construction, and anything
 * that must be refused to an anonymous caller has to arrive over the API from a
 * route that can refuse it. A `DeviceDeclaration` says *there is a microphone
 * on that desk, here is the hardware, here is what it can be asked to do*, and
 * publishing that is fine — it is a description of a room. A `DeviceState` says
 * what the microphone is hearing right now, and that never appears in a file
 * anybody can download.
 *
 * ### Strictly JSON-serialisable
 *
 * CONTRACT.md §2, non-negotiable, because a declaration is authored inside an
 * `Office` and a hand-written pack and one arriving over HTTP have to be the
 * same thing. No classes, no functions, no `Date`, no THREE types, no getters,
 * nothing that survives `structuredClone` but not `JSON.stringify`. The helpers
 * below are functions *about* the data, never fields *in* it.
 *
 * ### Two kinds now, five more later, without a new shape
 *
 * This build carries a mic and a speaker. A smart light, a thermostat, a door
 * sensor and a vehicle charger were all drawn on paper against this shape
 * before it was written, and all four fit: each is an authored declaration
 * anchored to a prop, a set of capabilities drawn from one closed list, and a
 * state carrying one reading per capability. Adding one is a new member of
 * `DeviceKind`, an entry in `CANONICAL_CAPABILITIES`, and — for a reading that
 * genuinely does not exist yet, such as a thermostat's setpoint — one optional
 * field on `DeviceState` and one row in `CAPABILITY_READING`. Nothing nests
 * differently, no authored pack is invalidated, and no consumer has to learn a
 * second way to ask what a device can do. That is what "fits without a schema
 * change" means here: the *structure* is fixed, the vocabulary grows.
 *
 * Weather is **not** a device and is not modelled as one. It is an observation
 * of the world that arrives from `/api/v1/weather`, it is nobody's hardware, it
 * is anchored to no prop, and there is nothing to command. `Environment` in
 * CONTRACT.md §4 is where it lives and it stays there.
 */

// ---- Identifiers ----------------------------------------------------------

/**
 * A namespaced asset id whose asset is device hardware, like
 * `"tera:device.mic.desk"`.
 *
 * The same string as `AssetId` in `src/assets/kit.ts` and in
 * `src/interiors/types.ts`, restated here for the reason those two give for
 * each other: this contract is data, and data should not have to import the
 * mesh library to be parsed, validated or stored. The narrower name is a
 * courtesy to the reader — not every `AssetId` is a device, and `assetId` on a
 * declaration is only ever one that is.
 *
 * The convention, which `deviceKindOfAssetId` reads and the API relies on, is
 * `<namespace>:device.<kind>.<placement>`. A self-hoster's
 * `acme:device.mic.boom` is a mic to every consumer here without registering
 * anything with us, which is the same reskinning story `overrides` tells for
 * furniture.
 */
export type DeviceAssetId = string;

// ---- Kinds ----------------------------------------------------------------

/** The device kinds this build carries. See the header on growing this list. */
export type DeviceKind = "mic" | "speaker";

export const DEVICE_KINDS: readonly DeviceKind[] = ["mic", "speaker"];

// ---- Capabilities ---------------------------------------------------------

/**
 * One thing a device can do or report — the unit both the UI and the simulator
 * are built out of.
 *
 * A capability is declared per *device*, not per kind, because two microphones
 * in the same building are genuinely not always the same instrument: the desk
 * condenser has a gain stage, the ceiling array does not. The device panel
 * renders one control per declared capability and the simulator advances one
 * reading per declared capability, so a device that declares nothing it does
 * not have cannot grow a control that does nothing.
 *
 * `level` is the odd one and is why this union is not simply a list of buttons:
 * it is a *reading only*. You can ask a microphone for its level; you cannot
 * set it. `DeviceCommandOp` is derived from this union by removing exactly
 * that, so the two can never drift apart.
 */
export type DeviceCapability = "power" | "gain" | "mute" | "volume" | "playback" | "level";

export const DEVICE_CAPABILITIES: readonly DeviceCapability[] = [
  "power",
  "gain",
  "mute",
  "volume",
  "playback",
  "level",
];

/** Everything a `DeviceCommand` may ask for: the capabilities that are not read-only. */
export type DeviceCommandOp = Exclude<DeviceCapability, "level">;

export const DEVICE_COMMAND_OPS: readonly DeviceCommandOp[] = [
  "power",
  "gain",
  "mute",
  "volume",
  "playback",
];

/**
 * What each kind normally has, for a pack author who wants the ordinary answer.
 *
 * Advisory, not enforced: a declaration carries its own `capabilities` and that
 * is what every consumer reads. This is here so that two studios authored
 * months apart describe the same instrument the same way, which is worth more
 * than it looks — the arena's observation vector is a fixed width, and a mic
 * that quietly stopped declaring `gain` in one pack would make two scenarios
 * incomparable.
 */
export const CANONICAL_CAPABILITIES: Readonly<Record<DeviceKind, readonly DeviceCapability[]>> = {
  mic: ["power", "mute", "gain", "level"],
  speaker: ["power", "volume", "playback"],
};

/**
 * The reading each capability implies on `DeviceState`.
 *
 * This table is the joint that makes the whole design hold. Without it, every
 * consumer would carry its own `if (kind === "mic") show gain` ladder and they
 * would disagree the first time a kind was added. With it: the UI renders
 * `capabilities.map(...)`, the simulator advances `capabilities.map(...)`, the
 * observation flattens `capabilities.map(...)`, and a new kind is data.
 */
export const CAPABILITY_READING: Readonly<Record<DeviceCapability, keyof DeviceState>> = {
  power: "powered",
  gain: "gainDb",
  mute: "muted",
  volume: "volume",
  playback: "playing",
  level: "levelDb",
};

// ---- Ranges ---------------------------------------------------------------

/**
 * The bounds of a numeric reading, in the units it is reported in.
 *
 * Published rather than left to each consumer because three of them need the
 * same numbers for different reasons and a disagreement would be silent: the
 * panel draws a slider between them, the API clamps a command into them, and
 * the arena normalises an observation by them. `initial` is the value at rest —
 * what an unobserved or freshly-powered device reports — which is what lets
 * `initialDeviceState` be a pure function of a declaration.
 */
export interface DeviceRange {
  min: number;
  max: number;
  /** The value at rest, before anything has been observed or commanded. */
  initial: number;
  unit: string;
}

/** The three numeric readings, as a type. Keyed by capability, not by kind. */
export type DeviceNumericCapability = "gain" | "volume" | "level";

export const DEVICE_RANGES: Readonly<Record<DeviceNumericCapability, DeviceRange>> = {
  /** Preamp gain on a desk condenser. Below zero is pad, not silence. */
  gain: { min: -12, max: 36, initial: 12, unit: "dB" },
  /** Output level, as a fraction. Not decibels: this is the knob, not the meter. */
  volume: { min: 0, max: 1, initial: 0.35, unit: "fraction" },
  /**
   * Programme level on the meter, full-scale referenced. At rest it sits at the
   * floor, because a microphone nobody has switched on is not hearing −20 dBFS
   * of anything.
   */
  level: { min: -60, max: 0, initial: -60, unit: "dBFS" },
};

// ---- Provenance -----------------------------------------------------------

/**
 * Where a device's readings come from, as a closed vocabulary.
 *
 * A closed union rather than free text, and the same idea as the per-row
 * provenance the marker gate enforces under CONTRACT.md §8: a value the server
 * can check is worth more than a sentence it can only pass through. What is
 * being guarded is different but the failure is identical — something presented
 * as observed when it was invented, or as invented when it was observed.
 *
 * - `simulated` — a deterministic state machine. Everything this build ships.
 * - `operator-authored` — a fixed value an operator wrote down. Honest, static.
 * - `first-party-sensor` — a real reading from the operator's own hardware,
 *   which is the door a Home Assistant bridge comes through later.
 *
 * `synthetic` on `DeviceState` is the runtime half of the same statement, and
 * it is the one a viewer is shown.
 */
export type DeviceProvenance = "simulated" | "operator-authored" | "first-party-sensor";

export const DEVICE_PROVENANCE: readonly DeviceProvenance[] = [
  "simulated",
  "operator-authored",
  "first-party-sensor",
];

// ---- The authored declaration ---------------------------------------------

/** Metres, in the anchor prop's own frame. */
export interface DeviceOffset {
  x: number;
  y: number;
  z: number;
}

/**
 * Where a device physically is — by reference, never by restatement.
 *
 * `propId` is required, and that is the whole design of this type: a device is
 * a piece of hardware, hardware sits on something, and the something is already
 * placed in the floorplan with a position and a rotation that `Plan` resolves.
 * Giving the declaration its own world coordinate would create a second answer
 * to "where is the mic", and the two would disagree the first time somebody
 * nudged the desk. So the position is *derived*: the anchor prop's transform,
 * plus an optional `offset` in the prop's own frame for the few centimetres
 * between the desk's origin and the top of the mic stand.
 *
 * The same argument `Prop.seat` makes for chairs, and the same one CONTRACT.md
 * makes about packs importing their site rather than restating its coordinates.
 *
 * `roomId` and `seatId` are addresses, not positions. They are what lets a
 * consumer ask "is anybody sitting where this mic is pointed" — which the arena
 * does, and which is the difference between a hot mic and a wasted one.
 */
export interface DeviceAnchor {
  levelId: string;
  /** The authored prop that *is* this device's hardware. */
  propId: string;
  /** The room the prop stands in, when a consumer wants it without a lookup. */
  roomId?: string;
  /** The seat this device serves, if it serves one. */
  seatId?: string;
  /** Metres from the anchor prop's origin, in the prop's frame. */
  offset?: DeviceOffset;
}

/**
 * One device, as a pack author writes it.
 *
 * Authored, public, and inert: nothing here is a reading and nothing here
 * changes. `Plan` resolves it the way it resolves every other authored address
 * — dropping what it cannot bind and recording the problem rather than throwing
 * — so one typo in a device id does not stop an office from opening.
 */
export interface DeviceDeclaration {
  /** Unique within the office. Referenced by every `DeviceState` and command. */
  id: string;
  kind: DeviceKind;
  /** Shown in the panel. "Desk mic", not "tera:device.mic.desk". */
  label: string;
  /** The hardware to build. Its kind segment must agree with `kind`. */
  assetId: DeviceAssetId;
  anchor: DeviceAnchor;
  /** What this particular unit can do. Usually `CANONICAL_CAPABILITIES[kind]`. */
  capabilities: readonly DeviceCapability[];
  /**
   * This unit's own bounds and units, where the global defaults are wrong for it.
   *
   * Absent is the ordinary case and means "use `DEVICE_RANGES`". It exists
   * because the defaults are a *desk condenser's* defaults — gain in decibels
   * from −12 to +36 — and real hardware does not agree with that. A Blue Yeti
   * Nano's capture level is an ALSA position on a 0–50 scale, an Anker C200's is
   * 0–100, and a ThinkPad's internal is 0–63. There is no arithmetic that turns
   * any of them into decibels: a mixer position is not a preamp measurement, and
   * rendering "68%" as "+20.6 dB" would present a guess in the typography of a
   * reading. So a declaration that knows better says so, in its own unit, and
   * three consumers read the same numbers: the panel draws the slider between
   * them, `normalizeDeviceCommand` clamps into them, and the arena normalises an
   * observation by them.
   *
   * Read it through `deviceRange()`, never directly — that helper is where the
   * `declaration.ranges?.[capability] ?? DEVICE_RANGES[capability]` fallback
   * lives once instead of in each consumer.
   *
   * Strictly JSON-serialisable, like everything else here: a `DeviceRange` is
   * four scalars.
   */
  ranges?: Partial<Record<DeviceNumericCapability, DeviceRange>>;
  provenance: DeviceProvenance;
  /**
   * One sentence shown to a viewer next to the readings.
   *
   * Mandatory, and validated: a `simulated` device must say so in words a
   * person reading the panel would understand, exactly as
   * `RobotOperationsDefinition.disclosure` is checked to contain "simulat"
   * before any of it is drawn. A studio that shows a live-looking level meter
   * without saying where the level came from is making a claim about a real
   * room, and this is the field that stops it.
   */
  disclosure: string;
  /**
   * The sentence a viewer sees **when this device's readings are being
   * simulated even though the declaration says they are not**.
   *
   * This closes a hole that only exists once a twin exists, and it is worth
   * stating the sequence because it is not obvious. A `first-party-sensor`
   * declaration's `disclosure` says something like "live, LA Studio, north
   * wall". An anonymous visitor cannot read the device route — it is a room
   * somebody is standing in — so `createDeviceSource` gives them the **local
   * simulator** instead, running that same declaration. Without this field the
   * panel would print "live, LA Studio, north wall" under a number invented one
   * millisecond ago in their own browser, which is precisely the
   * `first-party-sensor`/`simulated` confusion `DeviceProvenance` exists to
   * prevent, arriving through the honest path.
   *
   * Optional in the type and **required by the validator** whenever
   * `provenance !== "simulated"`, where it must contain the word "simulat" —
   * the same check `disclosure` itself gets for a simulated device, pointed at
   * the other half of the pair. Optional rather than required in the type
   * because every pack shipped before this field existed is `simulated` and
   * needs no second sentence; making it structurally mandatory would invalidate
   * authored packs to fix a problem none of them have.
   */
  simulatedDisclosure?: string;
}

// ---- The live state -------------------------------------------------------

/**
 * What a device is doing right now. **Never authored, never in a pack.**
 *
 * Every reading past `powered` is optional because a device only carries the
 * readings its capabilities imply — see `CAPABILITY_READING`. A consumer that
 * wants to know whether a field is meaningful asks the declaration, not the
 * state: `undefined` here means "this device has no such reading", which is a
 * different thing from zero.
 */
export interface DeviceState {
  id: string;
  kind: DeviceKind;
  /** Every device has this one. A device with no power state is a prop. */
  powered: boolean;
  muted?: boolean;
  gainDb?: number;
  /** Programme level, dBFS. A reading only — no command sets it. */
  levelDb?: number;
  volume?: number;
  playing?: boolean;
  /**
   * Could the thing holding this device be reached when the reading was taken?
   *
   * **Absent means the concept does not apply to this source**, which is the
   * case for everything simulated: a state machine in this process is never
   * unreachable, and a `reachable: true` there would be a fact about nothing.
   * Present and `false` means the last reading below still stands and the panel
   * says how old it is — it is emphatically **not** the same statement as
   * `powered: false`. A Blue Yeti behind an SSH hop on a box that is asleep is
   * not a switched-off microphone, and collapsing the two would report a room
   * as quiet when the truth is that nobody answered the door.
   *
   * There is deliberately no fifth indicator colour for it. An unreachable
   * device renders as the existing "off" grey and the sentence lives in the
   * panel, which has room for a sentence; a new colour in the 3D scene would
   * have to be learned, and would be learned wrong.
   */
  reachable?: boolean;
  /** Epoch milliseconds. */
  observedAt: number;
  /**
   * Was this reading invented?
   *
   * `true` for everything this build ships, and it is not a flag anybody may
   * default to `false` for convenience. The panel shows it, the wire carries
   * it, and `DevicesBody.synthetic` is the same statement one level up.
   */
  synthetic: boolean;
}

// ---- Commands -------------------------------------------------------------

/** Booleans for the switches, numbers for the knobs. Nothing else is a value. */
export type DeviceCommandValue = boolean | number;

/**
 * One instruction for one device.
 *
 * Deliberately tiny and deliberately not batched. It travels in a POST body of
 * its own — never in a read response, because a shared cache that replayed a
 * GET which turned a microphone on is precisely what the fail-closed
 * `Cache-Control` default in CONTRACT.md §5 exists to prevent.
 *
 * `value` is absent only for an op that carries no argument, and today there is
 * none: `power`, `mute` and `playback` take a boolean, `gain` and `volume` take
 * a number. It stays optional because a future `playback: "next"`-shaped op
 * would want it to be, and because a command that arrives without one must be
 * rejected by `normalizeDeviceCommand` rather than by the type system alone —
 * the wire can send anything.
 */
export interface DeviceCommand {
  deviceId: string;
  op: DeviceCommandOp;
  value?: DeviceCommandValue;
}

// ---- Helpers --------------------------------------------------------------
//
// Pure, total, and free of I/O. They exist because the alternative is four
// consumers each writing their own slightly different version of the same
// check, which is how a validation gate ends up being enforced in three places
// and skipped in the fourth.

export function isDeviceKind(value: unknown): value is DeviceKind {
  return typeof value === "string" && (DEVICE_KINDS as readonly string[]).includes(value);
}

export function isDeviceCapability(value: unknown): value is DeviceCapability {
  return typeof value === "string" && (DEVICE_CAPABILITIES as readonly string[]).includes(value);
}

export function isDeviceCommandOp(value: unknown): value is DeviceCommandOp {
  return typeof value === "string" && (DEVICE_COMMAND_OPS as readonly string[]).includes(value);
}

export function isDeviceProvenance(value: unknown): value is DeviceProvenance {
  return typeof value === "string" && (DEVICE_PROVENANCE as readonly string[]).includes(value);
}

/**
 * The kind an asset id claims to be, or `null` if it does not claim to be a
 * device at all.
 *
 * `"tera:device.mic.desk"` → `"mic"`. Namespace-agnostic, so a self-hoster's
 * `acme:device.speaker.shelf` reads as a speaker without registering anything.
 *
 * This is what lets the API check that a declaration's `assetId` and `kind`
 * agree, and check it *server-side* against the resolved plan rather than
 * trusting a body — the same move `officeHasMediaBinding()` makes for screens.
 * A mic declaration pointing at a speaker's hardware is not a rendering bug, it
 * is a command routed to the wrong instrument.
 */
export function deviceKindOfAssetId(assetId: DeviceAssetId): DeviceKind | null {
  const path = assetId.includes(":") ? assetId.slice(assetId.indexOf(":") + 1) : assetId;
  const segments = path.split(".");
  if (segments[0] !== "device") return null;
  const kind = segments[1];
  return isDeviceKind(kind) ? kind : null;
}

export function hasCapability(
  declaration: Pick<DeviceDeclaration, "capabilities">,
  capability: DeviceCapability,
): boolean {
  return declaration.capabilities.includes(capability);
}

/**
 * The bounds and unit this device's reading is actually in.
 *
 * One line, and it exists so that the line is written once. Three consumers
 * need the same numbers — the panel's slider, the command clamp, the arena's
 * normaliser — and a fourth reading `DEVICE_RANGES` directly would silently
 * present a 0–100 mixer position on a −12…+36 dB scale. That failure has no
 * symptom: the slider sits at the far right and the label says "+36 dB", which
 * is a perfectly plausible thing for a microphone to say.
 *
 * A declared range is trusted only if it is usable — two finite bounds with
 * `min < max`. A hand-edited pack is entitled to get that wrong, and falling
 * back is better than a slider with `NaN` on both ends.
 */
export function deviceRange(
  declaration: Pick<DeviceDeclaration, "ranges">,
  capability: DeviceNumericCapability,
): DeviceRange {
  const fallback = DEVICE_RANGES[capability];
  const declared = declaration.ranges?.[capability];
  if (declared === undefined || declared === null || typeof declared !== "object") return fallback;
  const { min, max, initial, unit } = declared as Partial<DeviceRange>;
  if (typeof min !== "number" || !Number.isFinite(min)) return fallback;
  if (typeof max !== "number" || !Number.isFinite(max)) return fallback;
  if (min >= max) return fallback;
  const restingRaw = typeof initial === "number" && Number.isFinite(initial) ? initial : min;
  return {
    min,
    max,
    initial: Math.min(max, Math.max(min, restingRaw)),
    unit: typeof unit === "string" && unit !== "" ? unit : fallback.unit,
  };
}

/**
 * Everything wrong with an authored declaration, as sentences. Empty is good.
 *
 * **It never throws**, which is the whole point: `Plan` resolves authored data
 * by dropping what it cannot use and recording why, so that one bad device does
 * not cost a viewer the building. Compare `resolveRobotOperations`, which does
 * throw — that is authored *behaviour*, resolved once at build time by whoever
 * wrote it, and a robot station with no floor under it is a bug in the pack. A
 * device is authored *furniture*, and furniture degrades.
 */
export function validateDeviceDeclaration(declaration: DeviceDeclaration): string[] {
  const problems: string[] = [];
  const id = declaration.id === "" ? "<unnamed device>" : declaration.id;

  if (declaration.id === "") problems.push("device has no id");
  if (!isDeviceKind(declaration.kind)) problems.push(`device ${id} has an unknown kind`);
  if (declaration.label.trim() === "") problems.push(`device ${id} has no label`);
  if (declaration.anchor.levelId === "") problems.push(`device ${id} names no level`);
  if (declaration.anchor.propId === "") {
    problems.push(`device ${id} is anchored to no prop, and a device with no hardware is fiction`);
  }

  const assetKind = deviceKindOfAssetId(declaration.assetId);
  if (assetKind === null) {
    problems.push(`device ${id} names ${declaration.assetId}, which is not device hardware`);
  } else if (assetKind !== declaration.kind) {
    problems.push(
      `device ${id} is declared a ${declaration.kind} but its asset ${declaration.assetId} is a ` +
        `${assetKind}`,
    );
  }

  if (declaration.capabilities.length === 0) {
    problems.push(`device ${id} declares no capabilities and could do nothing`);
  }
  for (const capability of declaration.capabilities) {
    if (!isDeviceCapability(capability)) {
      problems.push(`device ${id} declares an unknown capability ${String(capability)}`);
    }
  }
  if (!isDeviceProvenance(declaration.provenance)) {
    problems.push(`device ${id} has an unknown provenance`);
  }

  // The same check `resolveRobotOperations` makes, for the same reason: a
  // simulated reading displayed without the word is a claim about a real room.
  if (typeof declaration.disclosure !== "string" || declaration.disclosure.trim() === "") {
    problems.push(`device ${id} has no disclosure`);
  } else if (
    declaration.provenance === "simulated" &&
    !declaration.disclosure.toLowerCase().includes("simulat")
  ) {
    problems.push(`device ${id} is simulated and its disclosure does not say so`);
  }

  // The other half of the same rule, and the half that only matters once a
  // declaration claims real hardware. An anonymous visitor cannot read the
  // device route, so they are handed the local simulator running THIS
  // declaration — and `disclosure` on a live device says the room is live. The
  // second sentence is what the panel prints instead on that path. Required
  // here rather than in the type, so no already-authored simulated pack breaks;
  // see the field's own note.
  if (declaration.provenance !== "simulated") {
    const simulated = declaration.simulatedDisclosure;
    if (typeof simulated !== "string" || simulated.trim() === "") {
      problems.push(
        `device ${id} is not simulated and has no simulatedDisclosure — an anonymous viewer ` +
          "runs the local simulator under this declaration and would be shown the live sentence",
      );
    } else if (!simulated.toLowerCase().includes("simulat")) {
      problems.push(`device ${id} has a simulatedDisclosure that does not say it is simulated`);
    }
  }

  return problems;
}

/**
 * The state a declaration implies before anything has been observed.
 *
 * Powered off, every reading at rest, and `synthetic: true` whatever the
 * declaration's provenance says — because nothing has been observed yet, and a
 * state that claimed otherwise would be a lie told by a constructor.
 */
export function initialDeviceState(
  declaration: DeviceDeclaration,
  observedAt: number,
): DeviceState {
  const state: DeviceState = {
    id: declaration.id,
    kind: declaration.kind,
    powered: false,
    observedAt,
    synthetic: true,
  };
  if (hasCapability(declaration, "mute")) state.muted = false;
  // Through `deviceRange`, not `DEVICE_RANGES`: a Yeti declaring 0–100 "%" must
  // rest at its own resting value, not at +12 dB.
  if (hasCapability(declaration, "gain")) state.gainDb = deviceRange(declaration, "gain").initial;
  if (hasCapability(declaration, "level")) state.levelDb = deviceRange(declaration, "level").initial;
  if (hasCapability(declaration, "volume")) {
    state.volume = deviceRange(declaration, "volume").initial;
  }
  if (hasCapability(declaration, "playback")) state.playing = false;
  return state;
}

/**
 * A command this declaration will actually accept, clamped — or `null`.
 *
 * The one validator, run by everybody: the panel before it sends, the API
 * before it mutates, the simulator before it applies. `null` is a refusal and
 * the caller decides what that means — a 400 on the route, a no-op in the UI.
 * A returned command is a fresh object, so a caller cannot hold a reference to
 * something the store is about to mutate.
 *
 * Clamping rather than refusing an out-of-range number is deliberate and is the
 * one place this is lenient: a slider that reports 1.0000000002 is not an
 * attack, and `TERA_ADSB_RADIUS_NM` has the same disposition for the same
 * reason. A *wrong type* is refused, because that is a caller who has
 * misunderstood the contract rather than one who overshot.
 */
export function normalizeDeviceCommand(
  declaration: DeviceDeclaration,
  command: DeviceCommand,
): DeviceCommand | null {
  if (command.deviceId !== declaration.id) return null;
  if (!isDeviceCommandOp(command.op)) return null;
  if (!hasCapability(declaration, command.op)) return null;

  if (command.op === "gain" || command.op === "volume") {
    const range = deviceRange(declaration, command.op);
    if (typeof command.value !== "number" || !Number.isFinite(command.value)) return null;
    return {
      deviceId: declaration.id,
      op: command.op,
      value: Math.min(range.max, Math.max(range.min, command.value)),
    };
  }

  if (typeof command.value !== "boolean") return null;
  return { deviceId: declaration.id, op: command.op, value: command.value };
}

/**
 * A string that changes exactly when something a viewer would notice changes.
 *
 * `observedAt` is left out on purpose. It moves on every poll of an unchanged
 * room and would defeat the whole comparison — which is the same trap, and the
 * same answer, as the presence watch in `adapters/http.ts`. `watchDevices`
 * publishes on a change of this and on nothing else.
 */
export function deviceStateSignature(states: readonly DeviceState[]): string {
  return states
    .map((s) =>
      [
        s.id,
        s.powered ? "1" : "0",
        s.muted === undefined ? "" : s.muted ? "1" : "0",
        s.gainDb === undefined ? "" : s.gainDb.toFixed(2),
        s.levelDb === undefined ? "" : s.levelDb.toFixed(1),
        s.volume === undefined ? "" : s.volume.toFixed(3),
        s.playing === undefined ? "" : s.playing ? "1" : "0",
        // A device that stopped answering is the single most important change a
        // viewer can be shown, and without it here the panel would never
        // republish: every other reading holds its last value by design when a
        // bridge goes quiet, so the signature would be identical forever.
        s.reachable === undefined ? "" : s.reachable ? "1" : "0",
        s.synthetic ? "1" : "0",
      ].join("|"),
    )
    .join(";");
}
