/**
 * The first-party bridge, and the four things it must refuse.
 *
 * This is the only source in the build that reads a machine in a room with
 * people in it, so the tests are mostly about what does *not* come out of it.
 *
 * The upstream body below is not invented: it is `GET /api/la-studio/state`,
 * shortened but field-for-field real, including every field that must not
 * survive the hop. `positionNote` describes where hardware sits relative to
 * furniture ("The ThinkPad sits clamshell-CLOSED on top of the two ATX
 * boxes…"); `device` is an ALSA or PulseAudio path naming a serial number;
 * `hostLabel` names machines; `layout` is a paragraph describing the room;
 * `error` is free text from somebody else's shell. A mapper written as
 * `{ ...mic.state }` would put all of it on a public wire and the panel would
 * look identical.
 *
 * The other three refusals are units and recording:
 *
 * - **No level.** There is no passive level upstream; obtaining one calls
 *   `POST /levels`, which records 1.5–3 s of audio per microphone. A mirrored
 *   level meter would be a permanently open microphone in somebody's room, and
 *   it would look like a feature while doing it.
 * - **No invented decibels.** Upstream's `gainPct` is normalised over four
 *   different native scales — a Yeti Nano's ALSA range is 0–50, an Anker's is
 *   0–100, a ThinkPad's is 0–63. "68%" is a mixer position and "+20.6 dB" would
 *   be a guess wearing the typography of a measurement.
 * - **No writes.** A POST from a public page that unmutes a microphone in an
 *   occupied room is a decision nobody has made.
 */

import assert from "node:assert/strict";
import { after, beforeEach, describe, it } from "node:test";
import { commandRefusal, createFirstPartySource } from "../devices/firstParty.ts";
import { createDevicesService } from "../devices/index.ts";
import { loadConfig } from "../config.ts";
import type { DeviceDeclaration } from "../../../src/devices/types.ts";
import type { Office } from "../../../src/interiors/types.ts";

/** Every key the mapper may produce on a reading. Equality, not absence. */
const READING_KEYS = ["gainPct", "id", "muted", "observedAt", "reachable", "volume"];

/** Every key the mapper may produce on a `DeviceState` for a mic with a gain range. */
const STATE_KEYS = [
  "gainDb",
  "id",
  "kind",
  "muted",
  "observedAt",
  "powered",
  "reachable",
  "synthetic",
];

/**
 * The strings that describe the operator's house, their machines and their
 * plumbing. None of them may appear anywhere in a serialised reading.
 */
const FORBIDDEN_VALUES = [
  "clamshell-CLOSED",
  "plughw:2,0",
  "alsa_input.usb-Anker_PowerConf_C200",
  "alsa_output.pci-0000_0c_00.4.analog-stereo",
  "karti-os · office EQ",
  "veronica-thinkpad",
  "office desk",
  "north-west corner",
  "ssh: connect to host",
  "One room: the office desk",
  "40/50 is the good point",
];

const UPSTREAM = {
  state: {
    room: {
      name: "LA Studio",
      layout: "One room: the office desk (karti-os EQ + Yeti) faces the north wall…",
      ledCast: true,
    },
    mics: [
      {
        info: {
          id: "mic-yeti",
          label: "Blue Yeti Nano",
          host: "karti-os",
          hostLabel: "karti-os · office EQ",
          position: "office desk",
          positionNote: null,
          control: "alsa",
          device: "plughw:2,0",
          mixerControl: "Mic",
          nativeMin: 0,
          nativeMax: 50,
          recommendedPct: 80,
          recommendedNote: "Office-desk Yeti Nano. 40/50 is the good point.",
          tdoaPairId: null,
        },
        state: {
          id: "mic-yeti",
          muted: false,
          gainPct: 68,
          gainRaw: 34,
          reachable: true,
          error: null,
          checkedAt: "2026-08-22T22:11:29.345Z",
        },
      },
      {
        info: {
          id: "mic-thinkpad",
          label: "ThinkPad ALC285 internal",
          host: "veronica-thinkpad",
          hostLabel: "veronica-thinkpad · X1 Extreme",
          position: "on top of the ATX stack",
          positionNote:
            "The ThinkPad sits clamshell-CLOSED on top of the two ATX boxes, so its mic faces down.",
          control: "alsa",
          device: "plughw:1,0",
          nativeMax: 63,
        },
        state: {
          id: "mic-thinkpad",
          muted: true,
          gainPct: 19,
          gainRaw: 12,
          reachable: false,
          error: "ssh: connect to host veronica-thinkpad port 22: No route to host",
          checkedAt: "2026-08-22T22:11:29.353Z",
        },
      },
      {
        info: {
          id: "mic-anker",
          label: "Anker PowerConf C200 mic",
          position: "north-west corner",
          device:
            "alsa_input.usb-Anker_PowerConf_C200_Anker_PowerConf_C200_ACNV9P1D31370212-02.analog-stereo",
        },
        state: {
          id: "mic-anker",
          muted: false,
          gainPct: 90,
          reachable: true,
          error: null,
          checkedAt: "2026-08-22T22:11:29.053Z",
        },
      },
    ],
    cameras: [
      {
        info: { id: "cam-smy18", frigateName: "bedroom_north", position: "north wall" },
        state: { id: "cam-smy18", reachable: true, lastFrame: { frameId: "a4456963" } },
      },
    ],
    speaker: {
      sink: "sink-desk",
      sinks: [
        {
          id: "sink-desk",
          label: "ALC1220 desk speakers",
          pulseName: "alsa_output.pci-0000_0c_00.4.analog-stereo",
          isDefault: true,
        },
      ],
      muted: false,
      sinkVolumePct: 100,
      alsaMaster: 87,
      paplayVolume: 36000,
      ladder: "default",
      reachable: true,
      error: null,
      checkedAt: "2026-08-22T22:11:28.967Z",
    },
    hosts: [{ id: "karti-os", label: "karti-os · office EQ", reachable: true, ms: 434 }],
    services: [{ key: "tts", label: "Chatterbox TTS", host: "spark-1 · GB10", ok: true }],
    probedAt: "2026-08-22T22:11:29.353Z",
    cacheAgeMs: 5004,
  },
};

const realFetch = globalThis.fetch;
let calls: string[] = [];
let headersSent: Record<string, string>[] = [];
let upstreamUp = true;

globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
  calls.push(String(input));
  headersSent.push((init?.headers as Record<string, string> | undefined) ?? {});
  if (!upstreamUp) return new Response("nope", { status: 503 });
  return new Response(JSON.stringify(UPSTREAM), {
    status: 200,
    headers: { "content-type": "application/json" },
  });
}) as unknown as typeof globalThis.fetch;

after(() => {
  globalThis.fetch = realFetch;
});

beforeEach(() => {
  calls = [];
  headersSent = [];
  upstreamUp = true;
});

const silent = { warn: () => {} };

const STUDIO = { url: "http://127.0.0.1:9/api/la-studio", key: "shh", ttlSeconds: 30 };

/**
 * A mic that declares its gain in the upstream's own unit.
 *
 * Not decibels, deliberately: a Yeti Nano's capture level is an ALSA position on
 * a 0–50 scale and there is no arithmetic that turns it into a preamp figure.
 */
const YETI: DeviceDeclaration = {
  id: "mic-yeti",
  kind: "mic",
  label: "Blue Yeti Nano",
  assetId: "tera:device.mic.desk",
  anchor: { levelId: "ground", propId: "desk-01" },
  capabilities: ["power", "mute", "gain", "level"],
  ranges: { gain: { min: 0, max: 100, initial: 68, unit: "%" } },
  provenance: "first-party-sensor",
  disclosure: "Live reading from the studio's own desk microphone.",
  simulatedDisclosure: "Simulated in your browser — this deployment will not share the live room.",
};

/** The same instrument with no declared range, which must lose its gain row. */
const YETI_NO_RANGE: DeviceDeclaration = { ...YETI, id: "mic-anker", ranges: undefined };

const SPEAKER: DeviceDeclaration = {
  id: "speaker",
  kind: "speaker",
  label: "Desk speakers",
  assetId: "tera:device.speaker.desk",
  anchor: { levelId: "ground", propId: "desk-01" },
  capabilities: ["power", "volume", "playback"],
  provenance: "first-party-sensor",
  disclosure: "Live reading from the studio's own desk speakers.",
  simulatedDisclosure: "Simulated in your browser — this deployment will not share the live room.",
};

describe("the field allowlist", () => {
  it("maps exactly the allowed keys and nothing the upstream volunteered", async () => {
    const source = createFirstPartySource(STUDIO, silent);
    const snapshot = await source.read();
    assert.ok(snapshot !== null);

    const yeti = snapshot.readings.get("mic-yeti");
    assert.ok(yeti !== undefined);
    // Equality, not absence: a subset assertion passes for every field nobody
    // thought to forbid, and the field nobody thought of is the one that leaks.
    assert.deepEqual(Object.keys(yeti).sort(), READING_KEYS.filter((k) => k !== "volume").sort());
  });

  it("carries no sentence about the operator's house, machines or plumbing", async () => {
    const source = createFirstPartySource(STUDIO, silent);
    const snapshot = await source.read();
    const wire = JSON.stringify([...(snapshot?.readings.values() ?? [])]);
    for (const value of FORBIDDEN_VALUES) {
      assert.ok(!wire.includes(value), value);
    }
    // Not even the free-text error, which is the one that looks harmless and is
    // a shell message naming a hostname.
    assert.ok(!wire.includes("No route to host"));
  });

  it("ignores the camera list entirely", async () => {
    const source = createFirstPartySource(STUDIO, silent);
    const snapshot = await source.read();
    assert.equal(snapshot?.readings.get("cam-smy18"), undefined);
    // `bedroom_north` is a Frigate camera name and names the room. Nothing here
    // reads cameras at all, so it cannot arrive by any path.
    assert.ok(!JSON.stringify([...(snapshot?.readings ?? [])]).includes("bedroom"));
  });

  it("reads the speaker under both the literal id and its active sink", async () => {
    const source = createFirstPartySource(STUDIO, silent);
    const snapshot = await source.read();
    // One reading, two names, so a pack may declare whichever reads better in
    // its own floorplan without this file knowing which it chose.
    assert.equal(snapshot?.readings.get("speaker")?.volume, 1);
    assert.equal(snapshot?.readings.get("sink-desk")?.volume, 1);
    assert.equal(snapshot?.readings.get("speaker")?.muted, false);
  });

  it("sends the studio key and asks only for /state", async () => {
    const source = createFirstPartySource(STUDIO, silent);
    await source.read();
    assert.deepEqual(calls, ["http://127.0.0.1:9/api/la-studio/state"]);
    assert.equal(headersSent[0]?.["x-studio-key"], "shh");
    // `/sleep` reports whether the owner is asleep and `/automations` returns a
    // voice assistant's transcribed speech. They are one path segment away and
    // nothing may ever reach for them.
    assert.ok(!calls.some((c) => c.includes("/sleep") || c.includes("/automations")));
    assert.ok(!calls.some((c) => c.includes("/levels") || c.includes("/measure")));
    assert.ok(!calls.some((c) => c.includes("live.jpg") || c.includes("/frames/")));
  });
});

describe("the two unit refusals", () => {
  it("never reports a level, because obtaining one records the room", async () => {
    const source = createFirstPartySource(STUDIO, silent);
    const snapshot = await source.read();
    const state = source.stateFor(YETI, snapshot?.readings.get("mic-yeti"), 1);

    // The declaration asks for `level`. There is no passive level upstream, so
    // the reading is absent and the panel's meter row hides itself — which is
    // what `undefined` on `DeviceState` has always meant.
    assert.equal(state.levelDb, undefined);
    assert.deepEqual(Object.keys(state).sort(), STATE_KEYS.sort());
  });

  it("names a gain only under a declared non-decibel range", async () => {
    const source = createFirstPartySource(STUDIO, silent);
    const snapshot = await source.read();

    // Declared as 0–100 "%": the number means something and is carried.
    const declared = source.stateFor(YETI, snapshot?.readings.get("mic-yeti"), 1);
    assert.equal(declared.gainDb, 68);

    // No declared range means the global default, which is decibels. 90% of a
    // mixer travel is not 90 dB and it is not any other number of decibels
    // either, so no gain is reported at all.
    const undeclared = source.stateFor(YETI_NO_RANGE, snapshot?.readings.get("mic-anker"), 1);
    assert.equal(undeclared.gainDb, undefined);
  });
});

describe("reachability is not power", () => {
  it("reports an unreachable device as unreachable, holding its last reading", async () => {
    const source = createFirstPartySource(STUDIO, silent);
    const snapshot = await source.read();
    const state = source.stateFor(
      { ...YETI, id: "mic-thinkpad" },
      snapshot?.readings.get("mic-thinkpad"),
      1,
    );

    assert.equal(state.reachable, false);
    // A microphone on a machine that will not answer is not a switched-off
    // microphone, and the mute reading it last reported still stands.
    assert.equal(state.muted, true);
    assert.equal(state.synthetic, false);
    assert.equal(state.observedAt, Date.parse("2026-08-22T22:11:29.353Z"));
  });

  it("reports every declared device as unreachable when nobody answers", async () => {
    upstreamUp = false;
    const source = createFirstPartySource(STUDIO, silent);
    const snapshot = await source.read();
    assert.equal(snapshot, null);

    const state = source.stateFor(YETI, undefined, 4_242);
    assert.equal(state.reachable, false);
    assert.equal(state.powered, false);
    assert.equal(state.observedAt, 4_242);
    // Never invented. A bridge that started making readings up when the room
    // went quiet is the one substitution `DeviceProvenance` exists to prevent.
    assert.equal(state.synthetic, false);
    assert.equal(state.muted, undefined);
  });
});

describe("how often it probes, and what it will not do", () => {
  it("holds its own cache over the upstream's, so SSH is not fanned out per poll", async () => {
    const source = createFirstPartySource(STUDIO, silent);
    await source.read();
    await source.read();
    await source.read();
    // `/state` cold-probes three machines over SSH in about half a second, and
    // Tera's device TTL is five seconds. One call is the whole point.
    assert.equal(calls.length, 1);
  });

  it("floors a zero TTL rather than probing on every request", async () => {
    const source = createFirstPartySource({ ...STUDIO, ttlSeconds: 0 }, silent);
    await source.read();
    await source.read();
    assert.equal(calls.length, 1);
  });

  it("makes no request at all when no bridge URL is configured", async () => {
    const source = createFirstPartySource({ url: "", key: "", ttlSeconds: 30 }, silent);
    assert.equal(await source.read(), null);
    assert.deepEqual(calls, []);
  });
});

describe("the service, wired to the bridge", () => {
  /**
   * A studio with two real props and two first-party declarations.
   *
   * Written out rather than borrowed from a shipped pack, exactly as
   * `devices.test.ts` argues: a bridge test that fails because somebody moved a
   * desk in Los Angeles is a test nobody trusts.
   */
  const office: Office = {
    id: "studio",
    name: "Studio",
    viewpoints: [],
    levels: [
      {
        id: "l1",
        name: "Ground",
        elevation: 0,
        wallHeight: 3,
        floorplan: {
          rooms: [
            {
              id: "room",
              name: "Room",
              floor: "tera:carpet.loop",
              outline: [
                { x: 0, z: 0 },
                { x: 4, z: 0 },
                { x: 4, z: 4 },
                { x: 0, z: 4 },
              ],
            },
          ],
          walls: [],
          seats: [{ id: "desk-01", position: { x: 2, z: 2 }, facing: 0 }],
          props: [
            {
              id: "mic-prop",
              kind: "tera:device.mic.desk",
              position: { x: 2, z: 2 },
              rotation: 0,
            },
            {
              id: "speaker-prop",
              kind: "tera:device.speaker.desk",
              position: { x: 3, z: 2 },
              rotation: 0,
            },
          ],
          devices: [
            { ...YETI, anchor: { levelId: "l1", propId: "mic-prop", seatId: "desk-01" } },
            { ...SPEAKER, anchor: { levelId: "l1", propId: "speaker-prop" } },
          ],
        },
      },
    ],
  } as unknown as Office;

  function service() {
    const config = loadConfig({
      TERA_DEVICES_SOURCE: "first-party",
      TERA_STUDIO_URL: STUDIO.url,
      TERA_STUDIO_KEY: STUDIO.key,
    });
    return { config, devices: createDevicesService(config, silent) };
  }

  it("serves an observed body rather than a synthetic one", async () => {
    const { config, devices } = service();
    assert.equal(config.devices.source, "first-party");
    assert.deepEqual(config.degraded, []);

    const body = await devices.current(office);
    assert.equal(body.source, "first-party");
    // The whole reason the source exists: nobody in this process invented this.
    assert.equal(body.synthetic, false);
    assert.equal(body.devices.length, 2);
    assert.equal(body.devices.find((d) => d.id === "mic-yeti")?.muted, false);
    assert.equal(body.devices.find((d) => d.id === "speaker")?.volume, 1);
  });

  it("refuses every command, and says why", async () => {
    const { devices } = service();
    const outcome = devices.command(office, { deviceId: "mic-yeti", op: "mute", value: true });
    assert.equal(outcome.ok, false);
    assert.equal(outcome.ok === false ? outcome.reason : "", commandRefusal());
    assert.match(commandRefusal(), /does not command it/);
    // And it refused without asking the room anything.
    assert.deepEqual(calls, []);
  });

  it("demotes to none, never to sim, when it has nowhere to point", () => {
    const config = loadConfig({ TERA_DEVICES_SOURCE: "first-party" });
    assert.equal(config.devices.source, "none");
    assert.equal(config.degraded.length, 1);
    assert.match(config.degraded[0] ?? "", /TERA_STUDIO_URL/);
    // Not `sim`. Answering a promise of real hardware with a state machine is
    // the confusion this whole vocabulary exists to prevent.
    assert.ok(!(config.degraded[0] ?? "").includes("=sim"));
  });
});
