/**
 * The mic and the monitor speaker, drawn as instruments rather than as a table
 * of key/value pairs.
 *
 * There was no device concept anywhere in this product a week ago — no type, no
 * route, no state — so this panel is not a rewrite of anything and has no legacy
 * to preserve. What it does have is a rule it must not break, and it is the same
 * rule the media-surface panel and `RobotOperationsDefinition` already live
 * under: **a reading that was invented has to say so, on the same surface as the
 * reading.** A level meter bouncing next to a photograph of a desk is a claim
 * about a real room. `DeviceDeclaration.disclosure` is the sentence that makes it
 * an honest one, `validateDeviceDeclaration` refuses a `simulated` device whose
 * disclosure does not contain the word, and this panel prints it under every
 * instrument rather than once at the top — because a panel that scrolls is a
 * panel whose header you have already scrolled past.
 *
 * ### One control per declared capability, and never a `switch` on kind
 *
 * The controls are built by walking `declaration.capabilities` and looking each
 * one up in `CAPABILITY_READING`. Nothing here asks whether a device is a mic.
 * That is the whole design of `src/devices/types.ts` and it is what makes a
 * third kind — a smart light, a thermostat — data rather than a code change: the
 * capability is declared, the reading is named by the table, and the control
 * falls out. A `if (kind === "mic") renderGain()` ladder would have to be found
 * and edited in four consumers the first time somebody added one.
 *
 * `level` is the odd one and is deliberately not a control: it is a *reading*.
 * You can ask a microphone for its programme level; you cannot set it.
 * `DeviceCommandOp` is `Exclude<DeviceCapability, "level">` for exactly this
 * reason, so the type system agrees with the panel about which rows are knobs.
 *
 * ### Anonymous visitors get the panel
 *
 * Declarations are authored into the office pack, which is bundled into the
 * static build, which means they are public by construction — the hardware in
 * the room is a description of the room. What an account buys is the live
 * readings from the authenticated route. So this mounts for everyone and shows
 * the at-rest state from `initialDeviceState` until something better arrives,
 * and the disclosure line is what tells you which of the two you are looking at.
 */

import {
  CAPABILITY_READING,
  DEVICE_RANGES,
  deviceRange,
  initialDeviceState,
  normalizeDeviceCommand,
} from "../devices/types.ts";
import type {
  DeviceCapability,
  DeviceCommand,
  DeviceDeclaration,
  DeviceRange,
  DeviceState,
} from "../devices/types.ts";
import { TOUCH_TARGET_PX } from "./tokens.ts";

// ---- Options and handle ---------------------------------------------------

export interface DevicePanelOptions {
  /**
   * The authored hardware in this room, from the resolved `Plan`.
   *
   * Required, and this is the one addition to the signature the build spec
   * fixed: the panel's structure is a function of the declarations and there is
   * no honest way to render a control without one — the capability list, the
   * label, the ranges and the disclosure all come from here. States alone would
   * leave the panel guessing which readings a device is *supposed* to have,
   * which is precisely the distinction `undefined` is reserved for.
   */
  declarations: readonly DeviceDeclaration[];
  /**
   * Send one instruction. Already validated and clamped by
   * `normalizeDeviceCommand` before it arrives; a refusal is a silent no-op here
   * and never reaches this callback.
   */
  onCommand(command: DeviceCommand): void;
  /**
   * False while nothing is connected — an anonymous visitor, or a build with no
   * server. The controls stay visible and stay pressable, because a disabled
   * mixing desk teaches nothing; what changes is the line under them.
   */
  live?: boolean;
}

export interface DevicePanelHandle {
  root: HTMLElement;
  apply(states: readonly DeviceState[]): void;
  dispose(): void;
}

// ---- Style ----------------------------------------------------------------

/**
 * Exported so the stylesheet test can assert the two properties that are easy to
 * lose and impossible to see in a Node test: that every interactive row clears
 * the 44px touch target, and that nothing in here writes a raw `z-index`.
 */
export const DEVICE_PANEL_CSS = `
.tera-devices { display: flex; flex-direction: column; gap: var(--s2, 8px); }
.tera-device {
  display: flex;
  flex-direction: column;
  gap: var(--s2, 8px);
  padding: var(--s3, 12px);
  border: 1px solid var(--hairline, rgba(255,255,255,.11));
  border-radius: var(--r-sm, 5px);
  background: rgba(255, 255, 255, 0.03);
}
.tera-device__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s2, 8px); }
.tera-device__name { font-size: 11px; letter-spacing: .04em; color: var(--ink, rgba(255,255,255,.78)); }
.tera-device__kind { font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3, rgba(255,255,255,.4)); }
.tera-device__row {
  display: grid;
  grid-template-columns: 4.5rem minmax(0, 1fr) 3.25rem;
  align-items: center;
  gap: var(--s2, 8px);
  min-height: ${TOUCH_TARGET_PX}px;
}
.tera-device__row[data-unavailable="true"] { display: none; }
.tera-device__label { font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3, rgba(255,255,255,.4)); }
.tera-device__value { font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-2, rgba(255,255,255,.56)); }
.tera-device__switch {
  min-height: ${TOUCH_TARGET_PX}px;
  min-width: ${TOUCH_TARGET_PX}px;
  padding: 0 var(--s3, 12px);
  font: inherit;
  font-size: 10px;
  letter-spacing: .08em;
  text-transform: uppercase;
  cursor: pointer;
  color: var(--ink-2, rgba(255,255,255,.56));
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid var(--hairline, rgba(255,255,255,.11));
  border-radius: var(--r-pill, 999px);
}
.tera-device__switch[aria-pressed="true"] {
  color: var(--amber-ink, #ffd68a);
  background: rgba(242, 177, 52, 0.2);
  border-color: rgba(242, 177, 52, 0.5);
}
.tera-device__slider { width: 100%; min-height: ${TOUCH_TARGET_PX}px; accent-color: var(--amber, #f2b134); }
.tera-device__meter {
  position: relative;
  height: 6px;
  border-radius: 3px;
  overflow: hidden;
  background: rgba(255, 255, 255, 0.08);
}
.tera-device__meter i {
  display: block;
  height: 100%;
  border-radius: 3px;
  background: linear-gradient(90deg, #6fd58f 0%, #f2b134 78%, #ff8f6b 100%);
  transition: width 120ms linear;
}
.tera-device__status {
  margin: 0;
  font-size: 9px;
  letter-spacing: .06em;
  text-transform: uppercase;
  color: var(--ink-3, rgba(255,255,255,.4));
}
.tera-device__status[data-reachable="false"] { color: var(--ink-2, rgba(255,255,255,.56)); }
.tera-device__disclosure {
  margin: 0;
  font-size: 9px;
  line-height: 1.6;
  color: var(--ink-3, rgba(255,255,255,.4));
  border-left: 2px solid rgba(242, 177, 52, 0.4);
  padding-left: var(--s2, 8px);
}
@media (prefers-reduced-motion: reduce) { .tera-device__meter i { transition: none; } }
`;

const STYLE_ID = "tera-device-panel-style";

// ---- Rows ----------------------------------------------------------------

/** How each capability presents itself. `level` is a meter because it is a reading. */
type RowShape = "switch" | "slider" | "meter";

const ROW_SHAPE: Readonly<Record<DeviceCapability, RowShape>> = {
  power: "switch",
  mute: "switch",
  playback: "switch",
  gain: "slider",
  volume: "slider",
  level: "meter",
};

const ROW_LABEL: Readonly<Record<DeviceCapability, string>> = {
  power: "Power",
  mute: "Mute",
  playback: "Play",
  gain: "Gain",
  volume: "Volume",
  level: "Level",
};

/**
 * The step a slider moves in, derived from the range rather than tabled against
 * the capability.
 *
 * It used to be a constant per capability — 1 for gain, 0.01 for volume — which
 * was right only while every gain in the world was the default −12…+36 dB. A
 * Yeti Nano declares 0–100 "%" and a step of 1 is still right there; a
 * hypothetical 0–1 gain would need 0.01. So the rule is about the span: a wide
 * range moves in whole units, a narrow one in hundredths.
 */
function sliderStep(range: DeviceRange): number {
  return range.max - range.min > 5 ? 1 : (range.max - range.min) / 100;
}

interface Row {
  capability: DeviceCapability;
  /** This device's own bounds and unit, or `null` for a switch. */
  range: DeviceRange | null;
  /**
   * How this row presents itself, carried rather than re-derived.
   *
   * Deliberately not an `instanceof HTMLButtonElement` check at apply time:
   * `HTMLButtonElement` is not a global in Node, so an `instanceof` against it
   * throws a `ReferenceError` rather than returning false — which would make
   * this module untestable outside a browser for no benefit at all.
   */
  shape: RowShape;
  root: HTMLElement;
  control: HTMLElement;
  /** The slider itself, for the two rows that have one. */
  input: HTMLInputElement | null;
  value: HTMLElement;
  meterFill: HTMLElement | null;
}

interface Instrument {
  declaration: DeviceDeclaration;
  rows: readonly Row[];
  /**
   * The one line per instrument that says which of the two sentences the viewer
   * is reading, and whether anybody answered the door.
   */
  status: HTMLElement;
  disclosure: HTMLElement;
}

/**
 * One reading, in the unit the *declaration* says it is in.
 *
 * The unit comes from the range and never from the capability, and that is the
 * whole reason `DeviceDeclaration.ranges` exists. A Blue Yeti Nano's capture
 * level is an ALSA position on a 0–50 scale; there is no arithmetic that turns
 * 68% of it into decibels, and printing "+20.6 dB" beside it would present a
 * guess in the typography of a measurement. The field on `DeviceState` is still
 * called `gainDb` — renaming it would break four consumers to fix a label — so
 * this is the one place that decides what the number is called on screen, and it
 * asks the declaration.
 */
function formatReading(
  capability: DeviceCapability,
  reading: unknown,
  range: DeviceRange | null,
): string {
  if (reading === undefined || reading === null) return "—";
  if (typeof reading === "boolean") {
    if (capability === "power") return reading ? "On" : "Off";
    if (capability === "mute") return reading ? "Muted" : "Open";
    return reading ? "Playing" : "Stopped";
  }
  if (typeof reading !== "number" || !Number.isFinite(reading)) return "—";
  const unit = range?.unit ?? "";
  // A fraction is the one unit nobody wants to read as a fraction.
  if (unit === "fraction") return `${Math.round(reading * 100)}%`;
  if (unit === "%") return `${Math.round(reading)}%`;
  if (unit === "dB") return `${reading >= 0 ? "+" : ""}${reading.toFixed(0)} dB`;
  if (unit === "dBFS") return `${reading.toFixed(0)} dBFS`;
  if (unit === "") return String(reading);
  return `${reading.toFixed(reading % 1 === 0 ? 0 : 2)} ${unit}`;
}

/**
 * A level as a fraction of the meter's travel. On the default range, `-60` dBFS
 * is empty and `0` is full.
 *
 * `range` is optional and defaults to the global one so that every existing
 * caller — and every test that already asserts on this — is unchanged. A device
 * that declared its own level range gets its own travel.
 */
export function meterFraction(levelDb: number | undefined, range?: DeviceRange): number {
  if (levelDb === undefined || !Number.isFinite(levelDb)) return 0;
  const { min, max } = range ?? DEVICE_RANGES.level;
  if (!(max > min)) return 0;
  return Math.min(1, Math.max(0, (levelDb - min) / (max - min)));
}

/**
 * How old a reading is, in the coarsest unit that is still true.
 *
 * Deliberately vague past an hour. A panel that said "not reached — last reading
 * 3h 41m ago" would be inviting a precision the underlying clock does not have:
 * `observedAt` is the bridge's own `checkedAt`, restamped through two caches.
 */
function sinceLabel(observedAt: number | undefined, nowMs: number = Date.now()): string {
  if (typeof observedAt !== "number" || !Number.isFinite(observedAt) || observedAt <= 0) {
    return "at an unknown time";
  }
  const seconds = Math.max(0, Math.round((nowMs - observedAt) / 1000));
  if (seconds < 90) return `${seconds}s ago`;
  const minutes = Math.round(seconds / 60);
  if (minutes < 90) return `${minutes} min ago`;
  const hours = Math.round(minutes / 60);
  return hours < 36 ? `${hours}h ago` : "over a day ago";
}

// ---- Mount ----------------------------------------------------------------

export function mountDevicePanel(
  host: HTMLElement,
  options: DevicePanelOptions,
): DevicePanelHandle {
  const doc = host.ownerDocument;
  const root = doc.createElement("section");
  root.className = "tera-devices";
  root.setAttribute("aria-label", "Studio hardware");

  // One stylesheet per document, not per panel: this panel is torn down and
  // rebuilt on every office switch, and a `<style>` per mount would accumulate
  // one copy of the same rules per building the visitor walked through.
  const head = doc.head ?? null;
  let styleAdded = false;
  if (head !== null && head.querySelector(`#${STYLE_ID}`) === null) {
    const style = doc.createElement("style");
    style.id = STYLE_ID;
    style.textContent = DEVICE_PANEL_CSS;
    head.append(style);
    styleAdded = true;
  }

  const instruments: Instrument[] = [];
  let disposed = false;
  /** Last known state per device, so a control can compute its own toggle. */
  const latest = new Map<string, DeviceState>();

  function send(declaration: DeviceDeclaration, command: DeviceCommand): void {
    if (disposed) return;
    // Validated here as well as on the route, and that is not belt-and-braces
    // duplication: this is the copy that stops the UI *sending* something the
    // route would refuse, so a slider that overshoots its range by a float
    // rounding error is clamped rather than round-tripped into a 400.
    const normalized = normalizeDeviceCommand(declaration, command);
    if (normalized === null) return;
    options.onCommand(normalized);
  }

  for (const declaration of options.declarations) {
    const card = doc.createElement("article");
    card.className = "tera-device";
    card.setAttribute("data-device", declaration.id);
    card.setAttribute("data-kind", declaration.kind);

    const head2 = doc.createElement("div");
    head2.className = "tera-device__head";
    const name = doc.createElement("span");
    name.className = "tera-device__name";
    name.textContent = declaration.label;
    const kind = doc.createElement("span");
    kind.className = "tera-device__kind";
    kind.textContent = declaration.kind;
    head2.append(name, kind);
    card.append(head2);

    const rows: Row[] = [];
    for (const capability of declaration.capabilities) {
      const shape = ROW_SHAPE[capability];
      if (shape === undefined) continue;

      const row = doc.createElement("div");
      row.className = "tera-device__row";
      row.setAttribute("data-capability", capability);

      const label = doc.createElement("span");
      label.className = "tera-device__label";
      label.textContent = ROW_LABEL[capability];

      const value = doc.createElement("span");
      value.className = "tera-device__value";
      value.textContent = "—";

      let control: HTMLElement;
      let input: HTMLInputElement | null = null;
      let meterFill: HTMLElement | null = null;
      let range: DeviceRange | null = null;

      if (shape === "switch") {
        const button = doc.createElement("button");
        button.type = "button";
        button.className = "tera-device__switch";
        button.setAttribute("aria-pressed", "false");
        button.textContent = ROW_LABEL[capability];
        button.addEventListener("click", () => {
          const current = latest.get(declaration.id);
          const reading = current?.[CAPABILITY_READING[capability]];
          // The command is the *negation of what is on screen*, not a blind
          // `true`: a toggle that always sends `true` is a button that works
          // once. `undefined` — no reading yet — is treated as off, which is
          // what `initialDeviceState` says a device at rest is.
          send(declaration, {
            deviceId: declaration.id,
            op: capability === "power" ? "power" : capability === "mute" ? "mute" : "playback",
            value: !(reading === true),
          });
        });
        control = button;
      } else if (shape === "slider") {
        // The device's own bounds, not the global defaults. See `deviceRange`.
        range = deviceRange(declaration, capability === "gain" ? "gain" : "volume");
        const step = sliderStep(range);
        const slider = doc.createElement("input");
        slider.type = "range";
        slider.className = "tera-device__slider";
        slider.min = String(range.min);
        slider.max = String(range.max);
        slider.step = String(step);
        slider.value = String(range.initial);
        slider.setAttribute("aria-label", `${declaration.label} ${ROW_LABEL[capability]}`);
        slider.addEventListener("input", () => {
          const parsed = Number(slider.value);
          if (!Number.isFinite(parsed)) return;
          send(declaration, {
            deviceId: declaration.id,
            op: capability === "gain" ? "gain" : "volume",
            value: parsed,
          });
        });
        control = slider;
        input = slider;
      } else {
        range = deviceRange(declaration, "level");
        const meter = doc.createElement("div");
        meter.className = "tera-device__meter";
        meter.setAttribute("role", "meter");
        meter.setAttribute("aria-valuemin", String(range.min));
        meter.setAttribute("aria-valuemax", String(range.max));
        const fill = doc.createElement("i");
        fill.style.width = "0%";
        meter.append(fill);
        meterFill = fill;
        control = meter;
      }

      row.append(label, control, value);
      card.append(row);
      rows.push({ capability, range, shape, root: row, control, input, value, meterFill });
    }

    // Per instrument, not once per panel. A disclosure at the top of a scrolling
    // list is a disclosure you have already scrolled past by the time you are
    // looking at the meter that needed it.
    const status = doc.createElement("p");
    status.className = "tera-device__status";
    status.textContent = "";
    const disclosure = doc.createElement("p");
    disclosure.className = "tera-device__disclosure";
    disclosure.textContent = declaration.disclosure;
    card.append(status, disclosure);

    root.append(card);
    instruments.push({ declaration, rows, status, disclosure });
  }

  host.append(root);

  function apply(states: readonly DeviceState[]): void {
    if (disposed) return;
    const byId = new Map(states.map((state) => [state.id, state]));
    latest.clear();
    for (const state of states) latest.set(state.id, state);

    for (const instrument of instruments) {
      const state = byId.get(instrument.declaration.id) ?? null;
      for (const row of instrument.rows) {
        const reading = state === null ? undefined : state[CAPABILITY_READING[row.capability]];
        // `undefined` is "this device has no such reading", which is a different
        // statement from zero and gets a different treatment: the row goes away
        // rather than sitting there reporting a confident 0 dB.
        row.root.setAttribute("data-unavailable", String(reading === undefined));
        row.value.textContent = formatReading(row.capability, reading, row.range);

        if (row.shape === "switch") {
          row.control.setAttribute("aria-pressed", String(reading === true));
        } else if (row.shape === "slider") {
          if (row.input !== null && typeof reading === "number" && Number.isFinite(reading)) {
            row.input.value = String(reading);
          }
        } else if (row.meterFill !== null) {
          const range = row.range ?? DEVICE_RANGES.level;
          const fraction = meterFraction(
            typeof reading === "number" ? reading : undefined,
            range,
          );
          row.meterFill.style.width = `${(fraction * 100).toFixed(1)}%`;
          row.control.setAttribute(
            "aria-valuenow",
            typeof reading === "number" ? reading.toFixed(1) : String(range.min),
          );
        }
      }

      applyProvenance(instrument, state);
    }
  }

  /**
   * The two sentences under one instrument, and which of them is true right now.
   *
   * There are three facts a viewer needs and only one of them is a reading:
   *
   * - **Which disclosure applies.** A `first-party-sensor` declaration's own
   *   `disclosure` says the hardware is live. An anonymous visitor is handed the
   *   *local simulator* running that same declaration — see
   *   `devices/adapter.ts` — and printing "live, LA Studio, north wall" over a
   *   number invented a millisecond ago in their own browser is the exact
   *   provenance confusion `DeviceProvenance` exists to prevent, arriving
   *   through the honest path. So when a state says `synthetic` and the
   *   declaration does not, `simulatedDisclosure` is what is shown.
   * - **Whether anybody answered.** `reachable: false` means the readings below
   *   still stand and nobody has been able to confirm them since. It is
   *   emphatically not `powered: false`: a microphone on a machine that is
   *   asleep is not a switched-off microphone. There is deliberately no fifth
   *   indicator colour in the 3D scene for it — a new colour would have to be
   *   learned, and would be learned wrong. The sentence lives here, where there
   *   is room for a sentence.
   * - **How old the reading is**, once it has stopped being confirmed.
   */
  function applyProvenance(instrument: Instrument, state: DeviceState | null): void {
    const { declaration } = instrument;
    const invented = state === null ? true : state.synthetic;
    const simulatedSentence = declaration.simulatedDisclosure;
    instrument.disclosure.textContent =
      invented && declaration.provenance !== "simulated" && typeof simulatedSentence === "string"
        ? simulatedSentence
        : declaration.disclosure;

    // Absent means the concept does not apply to this source, which is every
    // simulated device — a state machine in this tab is never unreachable, and a
    // line claiming it is reachable would be a fact about nothing.
    const reachable = state?.reachable;
    if (reachable === undefined) {
      instrument.status.removeAttribute("data-reachable");
      instrument.status.textContent = "";
      return;
    }
    instrument.status.setAttribute("data-reachable", String(reachable));
    instrument.status.textContent = reachable
      ? "Reached"
      : `Not reached — last reading ${sinceLabel(state?.observedAt)}`;
  }

  /**
   * Everything starts at rest rather than blank.
   *
   * `initialDeviceState` is a pure function of the declaration and is the same
   * one the simulator and the route start from, so the pre-connection panel is
   * not an invented placeholder — it is the honest answer to "what does this
   * device report when nobody has observed it": powered off, gain at its resting
   * value, meter at the floor, and `synthetic: true` whatever the declaration's
   * provenance claims. A panel of dashes would teach nothing about the
   * instrument; this teaches the whole of it before a single reading arrives.
   */
  apply(options.declarations.map((declaration) => initialDeviceState(declaration, 0)));

  return {
    root,
    apply,
    dispose() {
      if (disposed) return;
      disposed = true;
      root.remove();
      latest.clear();
      if (styleAdded) doc.querySelector(`#${STYLE_ID}`)?.remove();
    },
  };
}
