/**
 * A read-only bridge to an operator's own hardware, over their own network.
 *
 * The first source in this build that reports something nobody in this process
 * invented. It is also the first that reads a machine in a room with people in
 * it, so the two most important things in this file are both refusals.
 *
 * ### It refuses to write
 *
 * There is no command path through here, and that is a product decision rather
 * than an unfinished one. Reading the state of a room is the demonstration; a
 * POST from a public web page that unmutes a microphone in an occupied room is a
 * different product and nobody has decided to build it. `commandRefusal()` says
 * so out loud and `devices/index.ts` reports it, because a refusal that is only
 * an absent function is a refusal somebody adds by accident.
 *
 * ### It refuses to invent a level, and it refuses to invent decibels
 *
 * Two separate refusals that both look like missing features.
 *
 * **There is no passive level upstream.** `GET /state` reports, per microphone,
 * `{ muted, gainPct, gainRaw, reachable, error, checkedAt }` and nothing else. A
 * level requires `POST /levels`, which records one and a half to three seconds
 * of audio per microphone to measure it. Mirroring `level` at a device TTL would
 * be a permanently open microphone in somebody's room, and it would look like a
 * feature the entire time it was doing it. So `levelDb` is never mapped, the
 * panel's meter row simply has no reading, and the row hides itself — which is
 * what `undefined` on `DeviceState` has always meant.
 *
 * **There is no honest decibel figure.** Upstream speaks `gainPct`, normalised
 * over four different native scales: a Blue Yeti Nano's ALSA range is 0–50, an
 * SMY18's and an Anker C200's are 0–100, a ThinkPad's internal is 0–63. "68%" is
 * a mixer position. Rendering it as "+20.6 dB" would present a guess in the
 * typography of a measurement, and it would look completely plausible. So the
 * gain reading is emitted **only** when the declaration itself supplies a
 * `ranges.gain` that is not in decibels — a pack that says "0–100 %" gets its
 * number, and a pack that says nothing gets no gain row at all. Fail-closed, in
 * the direction where the missing thing is visible.
 *
 * That leaves mute, volume and reachability as the fields this build actually
 * puts on a public wire, which is the smallest set that still makes the room
 * real.
 *
 * ### The mapper is an allowlist, never a spread
 *
 * `/state` carries far more than the readings: `positionNote` (which describes
 * where hardware sits relative to furniture), ALSA and PulseAudio device paths,
 * sink names, host labels, free-text `error` strings, a room `layout`
 * description, and a `recommendedNote`. A `...mic.state` anywhere in here is the
 * bug, and it is the kind of bug that ships. Every field is named, one at a time,
 * below.
 *
 * ### Endpoints that must never be read from here
 *
 * The same server exposes `GET /sleep` — which answers whether the owner is
 * asleep, with the camera activity and lux readings behind it — and
 * `GET /automations`, which returns log tails including a voice assistant's
 * transcribed speech and occupancy edges. They are one path segment away, they
 * are rich, and they would make a room feel astonishingly alive. Nothing here
 * reads them, nothing here should, and a public 3D world that renders whether
 * its owner is asleep is not a feature with a privacy setting.
 *
 * ### Its own cache, longer than the device TTL
 *
 * `/state` cold-probes three machines over SSH — measured at half a second, with
 * karti-os alone taking 498 ms — and holds its own ten-second cache. Tera's
 * device TTL is five seconds. Polling the bridge on that clock, per open tab,
 * would fan SSH out to three machines the operator actually uses, forever. So
 * this holds thirty seconds of its own (`TERA_STUDIO_TTL`), and a miss serves
 * the last good snapshot rather than re-probing.
 *
 * ### It demotes to `live: false`, never to `sim`
 *
 * A bridge that quietly started inventing readings when the room stopped
 * answering would be the `first-party-sensor`/`simulated` confusion
 * `DeviceProvenance` exists to prevent, in the one direction that matters. When
 * nothing answers, the devices this bridge covers report their last known state
 * with `reachable: false`, and a device it has never heard about reports nothing
 * at all.
 */

import { getJson } from "../http.ts";
import { createUpstream } from "../upstream.ts";
import { deviceRange, hasCapability, type DeviceDeclaration } from "../../../src/devices/types.ts";
import type { DeviceState } from "../../../src/devices/types.ts";
import type { StudioConfig } from "../config.ts";

/** One reading, keyed by the id a pack declares. */
export type FirstPartyReadings = ReadonlyMap<string, FirstPartyReading>;

/**
 * The allowlist, as a type.
 *
 * Everything this build is willing to learn about somebody's room. Growing it is
 * a privacy decision and the questions to answer first are the two the header
 * asks: does obtaining it record anybody, and is the number in a unit this
 * repo can name without guessing.
 */
export interface FirstPartyReading {
  /** The upstream id, so a mismatch is diagnosable. Never rendered. */
  id: string;
  muted?: boolean;
  /** 0–100, in the upstream's own percent. Emitted only under a declared range. */
  gainPct?: number;
  /** 0–1. `sinkVolumePct / 100`, which is exact rather than a conversion. */
  volume?: number;
  reachable: boolean;
  /** Epoch ms, from the upstream's `checkedAt`. */
  observedAt: number;
}

export interface FirstPartySnapshot {
  readings: FirstPartyReadings;
  /** Epoch ms at which this box completed the fetch. */
  fetchedAt: number;
}

export interface FirstPartyLog {
  warn(msg: string): void;
}

export interface FirstPartySource {
  /** The freshest snapshot, or `null` if nothing has ever answered. */
  read(): Promise<FirstPartySnapshot | null>;
  /**
   * Turn one declaration plus one reading into a state.
   *
   * On this source, and not on `DeviceState`, because the mapping is where the
   * two refusals live: which fields are carried at all, and under what
   * conditions a gain figure may be named.
   */
  stateFor(
    declaration: DeviceDeclaration,
    reading: FirstPartyReading | undefined,
    observedAt: number,
  ): DeviceState;
}

/** The whole of the write surface. Stated, not merely absent. */
export function commandRefusal(): string {
  return (
    "this deployment reads its first-party hardware and does not command it — turning a " +
    "microphone on in an occupied room from a public page is a decision nobody has made"
  );
}

/** How long the bridge fetch may take. Three SSH probes cold. */
const TIMEOUT_MS = 6_000;

/** Bounds on the operator's dial, so neither end can become a probe storm. */
const MIN_TTL_SECONDS = 15;
const MAX_TTL_SECONDS = 600;

/** One key: the bridge answers about one studio. Same argument as satellites. */
const STUDIO_KEY = "studio";

/** A ceiling on how many devices one bridge may describe. `store.ts`'s number. */
const MAX_READINGS = 64;

export function createFirstPartySource(
  config: StudioConfig,
  log: FirstPartyLog,
): FirstPartySource {
  const ttl = Math.min(MAX_TTL_SECONDS, Math.max(MIN_TTL_SECONDS, config.ttlSeconds));
  const upstream = createUpstream<FirstPartySnapshot>({
    label: "devices:first-party",
    ttlSeconds: ttl,
    log,
  });

  return {
    async read(): Promise<FirstPartySnapshot | null> {
      if (config.url === "") return null;
      return upstream.get(STUDIO_KEY, () => fetchState(config));
    },
    stateFor: mapState,
  };
}

/**
 * One GET, mapped field by named field.
 *
 * `null` on every failure, which `upstream.ts` turns into "serve the last good
 * snapshot" — and, once the last good snapshot has aged, into a set of readings
 * that still carry `reachable: false`.
 */
async function fetchState(config: StudioConfig): Promise<FirstPartySnapshot | null> {
  const headers = config.key === "" ? undefined : { "x-studio-key": config.key };
  const body = await getJson<Record<string, unknown>>(`${config.url}/state`, {
    timeoutMs: TIMEOUT_MS,
    ...(headers === undefined ? {} : { headers }),
  });
  if (body === null) return null;

  const state = record(body.state);
  if (state === null) return null;

  const readings = new Map<string, FirstPartyReading>();
  const now = Date.now();

  // --- microphones -------------------------------------------------------
  //
  // `info` is read for exactly one field — the id — and `state` for four. Not
  // `positionNote`, which describes where hardware sits relative to furniture;
  // not `device`, which is an ALSA or PulseAudio path; not `host` or
  // `hostLabel`; not `recommendedNote`; and not `error`, which is free text
  // from somebody else's shell.
  for (const entry of array(state.mics)) {
    if (readings.size >= MAX_READINGS) break;
    const mic = record(entry);
    if (mic === null) continue;
    const info = record(mic.info);
    const reading = record(mic.state);
    if (reading === null) continue;
    const id = str(info?.id) ?? str(reading.id);
    if (id === null) continue;

    readings.set(id, {
      id,
      ...(typeof reading.muted === "boolean" ? { muted: reading.muted } : {}),
      ...(finite(reading.gainPct) === null ? {} : { gainPct: finite(reading.gainPct) as number }),
      reachable: reading.reachable === true,
      observedAt: epoch(reading.checkedAt) ?? now,
    });
  }

  // --- the speaker -------------------------------------------------------
  //
  // Upstream has one speaker object with a chosen sink, not a list. It is
  // published under two ids — the literal `speaker` and the active sink's own id
  // — so a pack may declare whichever reads better in its own floorplan without
  // this file having to know which one it chose. `sinks[]`, `pulseName`,
  // `alsaMaster`, `paplayVolume` and `ladder` are all left where they are:
  // they are the operator's audio plumbing, not a reading about a room.
  const speaker = record(state.speaker);
  if (speaker !== null) {
    const volumePct = finite(speaker.sinkVolumePct);
    const reading: FirstPartyReading = {
      id: str(speaker.sink) ?? "speaker",
      ...(typeof speaker.muted === "boolean" ? { muted: speaker.muted } : {}),
      // Percent to fraction is exact arithmetic in the same unit, which is why
      // it is allowed here and a percent-to-decibel conversion is not.
      ...(volumePct === null ? {} : { volume: Math.min(1, Math.max(0, volumePct / 100)) }),
      reachable: speaker.reachable === true,
      observedAt: epoch(speaker.checkedAt) ?? now,
    };
    readings.set("speaker", reading);
    const sinkId = str(speaker.sink);
    if (sinkId !== null && readings.size < MAX_READINGS) readings.set(sinkId, reading);
  }

  return { readings, fetchedAt: now };
}

/**
 * One declaration plus one reading, as a `DeviceState`.
 *
 * Exported through the source rather than as a free function so that the two
 * refusals stay attached to the thing that makes them: `levelDb` is never set,
 * and `gainDb` is set only under a declared non-decibel range.
 */
function mapState(
  declaration: DeviceDeclaration,
  reading: FirstPartyReading | undefined,
  observedAt: number,
): DeviceState {
  const state: DeviceState = {
    id: declaration.id,
    kind: declaration.kind,
    // A device the bridge could reach is a device that is on. There is no
    // separate power reading upstream — a microphone the host can enumerate is
    // powered, and one on a machine that will not answer is unreachable rather
    // than off. The two are different sentences and `reachable` carries the
    // second one.
    powered: reading?.reachable === true,
    reachable: reading?.reachable ?? false,
    observedAt: reading?.observedAt ?? observedAt,
    // The whole reason this source exists. Nothing here was invented.
    synthetic: false,
  };

  if (hasCapability(declaration, "mute") && reading?.muted !== undefined) {
    state.muted = reading.muted;
  }
  if (hasCapability(declaration, "volume") && reading?.volume !== undefined) {
    state.volume = reading.volume;
  }
  if (hasCapability(declaration, "gain") && reading?.gainPct !== undefined) {
    // The refusal, in one condition. Upstream's number is a percent of a mixer
    // travel; the global default range is decibels. Emitting it under the
    // default would mean printing "+68 dB" beside a microphone, which is both
    // wrong and plausible. A declaration that states its own non-decibel range
    // is a pack author saying what the number means, and only then is it named.
    const range = deviceRange(declaration, "gain");
    if (range.unit !== DEVICE_DEFAULT_GAIN_UNIT) {
      state.gainDb = Math.min(range.max, Math.max(range.min, reading.gainPct));
    }
  }
  // `levelDb` is never set. There is no passive level upstream and obtaining one
  // records the room. The panel's meter row hides itself on `undefined`, which
  // is what `undefined` has always meant here: this device has no such reading.
  return state;
}

/** The unit the global default gain range is in. Anything else is the pack's own. */
const DEVICE_DEFAULT_GAIN_UNIT = "dB";

// ---- Reading somebody else's JSON -----------------------------------------

function record(value: unknown): Record<string, unknown> | null {
  return value !== null && typeof value === "object" && !Array.isArray(value)
    ? (value as Record<string, unknown>)
    : null;
}

function array(value: unknown): unknown[] {
  return Array.isArray(value) ? value : [];
}

function str(value: unknown): string | null {
  if (typeof value !== "string") return null;
  const trimmed = value.trim();
  return trimmed === "" ? null : trimmed;
}

function finite(value: unknown): number | null {
  return typeof value === "number" && Number.isFinite(value) ? value : null;
}

/** An ISO timestamp as epoch ms, or `null` where it will not parse. */
function epoch(value: unknown): number | null {
  if (typeof value !== "string") return null;
  const ms = Date.parse(value);
  return Number.isFinite(ms) ? ms : null;
}
