/**
 * The device routes: the refusal, the ordering, and the write.
 *
 * Three properties carry this file, and the third is the one that is new to
 * this repo — everything before devices was read-only.
 *
 * **The refusal.** Device state describes a room somebody is standing in, so
 * the read takes a session unconditionally, exactly as occupancy does. An
 * anonymous caller gets an identical 401 for a real office, a private one and
 * an invented one, because the viewer is resolved before the id is looked at:
 * check the office first and the status codes tell them apart perfectly, which
 * is CONTRACT.md §6's enumeration oracle wearing a different number.
 *
 * **The cache.** Neither route may ever be publicly cached, in any
 * configuration, including the ones that answer 401 and 404. A shared cache
 * holding a body that took a credential to obtain is one viewer's studio served
 * to the next; a shared cache holding a *command* is a microphone that can be
 * switched on by replaying a request nobody made. Both are asserted, on every
 * status code, because `cache.ts` is fail-closed by default and the way that
 * default gets lost is a route quietly opting out.
 *
 * **The write.** A command is checked against the pack this process resolved
 * and not against anything in the request: an id that names no authored
 * declaration, an op the declaration never declared, or a value of the wrong
 * type is a 400 and — this is the assertion that matters — **no state
 * changes**. The read after the refused write is what proves it.
 */

import assert from "node:assert/strict";
import { createHmac } from "node:crypto";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, before, describe, it } from "node:test";
import { buildApp } from "../app.ts";
import { loadConfig } from "../config.ts";
import { forgetResolvedDevices, resolveDevices } from "../devices/store.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type { DeviceState } from "../../../src/devices/types.ts";
import type { DevicesBody } from "../../../src/server/wire.ts";

const SECRET = "not-a-real-secret-and-never-was";

const MIC = "tera:device.mic.desk";
const SPEAKER = "tera:device.speaker.desk";

/**
 * A studio with two real props and four declarations, three of which are wrong
 * in a different way.
 *
 * Written out rather than borrowed from a shipped pack on purpose: the shipped
 * packs are the `packs` workstream's to change, and a route test that fails
 * because somebody moved a desk in Los Angeles is a test nobody trusts. What is
 * asserted here is the *rule*, and the rule needs a pack that deliberately
 * breaks it.
 */
function studio(id: string): Office {
  return {
    id,
    name: "Test 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: 8, z: 0 },
                { x: 8, z: 6 },
                { x: 0, z: 6 },
              ],
            },
          ],
          walls: [],
          seats: [{ id: "desk-01", position: { x: 2, z: 2 }, facing: 0 }],
          props: [
            { id: "mic-prop", kind: MIC, position: { x: 2, z: 2 }, rotation: 0 },
            { id: "speaker-prop", kind: SPEAKER, position: { x: 3, z: 2 }, rotation: 0 },
            { id: "desk-prop", kind: "tera:desk.workstation", position: { x: 2, z: 2.4 }, rotation: 0 },
          ],
          devices: [
            {
              id: "mic-1",
              kind: "mic",
              label: "Desk mic",
              assetId: MIC,
              anchor: { levelId: "l1", propId: "mic-prop", seatId: "desk-01" },
              capabilities: ["power", "mute", "gain", "level"],
              provenance: "simulated",
              disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
            },
            {
              id: "speaker-1",
              kind: "speaker",
              label: "Monitor",
              assetId: SPEAKER,
              anchor: { levelId: "l1", propId: "speaker-prop" },
              capabilities: ["power", "volume", "playback"],
              provenance: "simulated",
              disclosure: "Simulated studio hardware. Demonstration data, never presence data.",
            },
            // Standing on a desk rather than being its own prop. This is the
            // ordinary case and it is **accepted**: `DeviceAnchor.offset` exists
            // for exactly those few centimetres, and a desk claims to be no
            // instrument at all, so there is nothing for it to disagree with.
            {
              id: "desk-mounted-mic",
              kind: "mic",
              label: "Boom mic",
              assetId: MIC,
              anchor: { levelId: "l1", propId: "desk-prop", offset: { x: 0, y: 0.74, z: 0.1 } },
              capabilities: ["power", "level"],
              provenance: "simulated",
              disclosure: "Simulated studio hardware.",
            },
            // Anchored to another instrument's hardware. A microphone bolted to
            // a speaker is not a rendering nit — it is a command routed to the
            // wrong instrument in a real room — and it is dropped.
            {
              id: "mic-on-a-speaker",
              kind: "mic",
              label: "Nothing",
              assetId: MIC,
              anchor: { levelId: "l1", propId: "speaker-prop" },
              capabilities: ["power", "level"],
              provenance: "simulated",
              disclosure: "Simulated.",
            },
            // Anchored to a prop that does not exist.
            {
              id: "mic-nowhere",
              kind: "mic",
              label: "Nothing",
              assetId: MIC,
              anchor: { levelId: "l1", propId: "no-such-prop" },
              capabilities: ["power", "level"],
              provenance: "simulated",
              disclosure: "Simulated.",
            },
            // Says it is simulated in its provenance and not in its words. The
            // same check `resolveRobotOperations` makes, applied by
            // `validateDeviceDeclaration` and enforced here.
            {
              id: "mic-undisclosed",
              kind: "mic",
              label: "Nothing",
              assetId: MIC,
              anchor: { levelId: "l1", propId: "mic-prop" },
              capabilities: ["power", "level"],
              provenance: "simulated",
              disclosure: "A microphone.",
            },
          ],
        },
      },
    ],
  } as unknown as Office;
}

let offices = "";

before(async () => {
  offices = await mkdtemp(join(tmpdir(), "tera-devices-"));
  await writeFile(
    join(offices, "open.json"),
    JSON.stringify({ id: "open", name: "Open", visibility: "public", floor: studio("open") }),
  );
  await writeFile(
    join(offices, "closed.json"),
    JSON.stringify({ id: "closed", name: "Closed", visibility: "private", floor: studio("closed") }),
  );
  await writeFile(
    join(offices, "bare.json"),
    JSON.stringify({
      id: "bare",
      name: "Bare",
      visibility: "public",
      floor: { id: "bare", name: "Bare", levels: [], viewpoints: [] },
    }),
  );
});

const jwt = { TERA_AUTH_MODE: "jwt", TERA_AUTH_JWT_SECRET: SECRET };
const sim = { TERA_DEVICES_SOURCE: "sim" };

function appWith(env: Record<string, string>) {
  const config = loadConfig({ TERA_OFFICES_DIR: offices, ...env });
  config.logLevel = "silent";
  return buildApp(config);
}

function hs256(claims: Record<string, unknown>): string {
  const encode = (value: unknown): string =>
    Buffer.from(JSON.stringify(value)).toString("base64url");
  const signed = `${encode({ alg: "HS256", typ: "JWT" })}.${encode(claims)}`;
  return `${signed}.${createHmac("sha256", SECRET).update(signed).digest("base64url")}`;
}

function bearer(): { authorization: string } {
  return {
    authorization: `Bearer ${hs256({ sub: "someone", exp: Math.floor(Date.now() / 1000) + 3600 })}`,
  };
}

const url = (id: string) => `/api/v1/offices/${id}/devices`;

describe("the refusal", () => {
  it("refuses an anonymous read even for a public office", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: url("open") });
    assert.equal(res.statusCode, 401);
    assert.equal(res.headers["www-authenticate"], "Bearer");
    // The point: the office itself is public and readable by this same caller.
    const doc = await app.inject({ method: "GET", url: "/api/v1/offices/open" });
    assert.equal(doc.statusCode, 200);
  });

  it("refuses an anonymous command", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const res = await app.inject({
      method: "POST",
      url: `${url("open")}/command`,
      payload: { command: { deviceId: "mic-1", op: "power", value: true } },
    });
    assert.equal(res.statusCode, 401);
  });

  it("tells a real, a private and an absent office apart for nobody", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const real = await app.inject({ method: "GET", url: url("open") });
    const priv = await app.inject({ method: "GET", url: url("closed") });
    const gone = await app.inject({ method: "GET", url: url("no-such-thing") });

    assert.equal(real.statusCode, 401);
    assert.equal(priv.statusCode, 401);
    assert.equal(gone.statusCode, 401);
    assert.deepEqual(real.json(), priv.json());
    assert.deepEqual(real.json(), gone.json());
  });

  it("answers 404, never 403, for an office a signed-in caller cannot see", async () => {
    // `offices/store.ts` refuses an id that is not filename-shaped, which is the
    // same 404 by a different door — both are "there is no office here".
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const gone = await app.inject({ method: "GET", url: url("no-such-thing"), headers: bearer() });
    assert.equal(gone.statusCode, 404);
    assert.equal(gone.json().error, "not_found");

    const traversal = await app.inject({ method: "GET", url: url(".."), headers: bearer() });
    assert.ok(traversal.statusCode === 404 || traversal.statusCode === 400);
  });
});

describe("the cache", () => {
  it("never lets a shared cache keep device state, whatever the answer was", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const anonymous = await app.inject({ method: "GET", url: url("open") });
    const missing = await app.inject({ method: "GET", url: url("nope"), headers: bearer() });
    const ok = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
    const commanded = await app.inject({
      method: "POST",
      url: `${url("open")}/command`,
      headers: bearer(),
      payload: { command: { deviceId: "mic-1", op: "power", value: true } },
    });

    for (const res of [anonymous, missing, ok, commanded]) {
      assert.equal(res.headers["cache-control"], "private, no-store");
    }
  });
});

describe("what the pack is allowed to declare", () => {
  it("serves only the declarations whose hardware is really there", () => {
    forgetResolvedDevices();
    const { declarations, authored } = resolveDevices(studio("open"));
    assert.deepEqual(
      declarations.map((d) => d.id),
      ["mic-1", "speaker-1", "desk-mounted-mic"],
    );
    // Every one of the six was authored, which is what lets the service tell
    // "no devices" from "no devices survived".
    assert.equal(authored, 6);
  });

  it("accepts a microphone standing on a desk, and refuses one bolted to a speaker", () => {
    forgetResolvedDevices();
    const { declarations } = resolveDevices(studio("open"));
    // The rule is `Plan`'s and there is deliberately no second copy of it here:
    // a stricter one on this side would drop a legitimate pack's devices from
    // the API while the browser went on drawing them, which is the worst kind
    // of disagreement — a populated panel whose every command is refused.
    assert.ok(declarations.some((d) => d.id === "desk-mounted-mic"));
    assert.equal(declarations.find((d) => d.id === "mic-on-a-speaker"), undefined);
    assert.equal(declarations.find((d) => d.id === "mic-nowhere"), undefined);
  });

  it("drops a simulated device whose disclosure does not say so", () => {
    forgetResolvedDevices();
    const { declarations } = resolveDevices(studio("open"));
    assert.equal(declarations.find((d) => d.id === "mic-undisclosed"), undefined);
  });

  it("is a 200 and an empty list for an office that declares nothing", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: url("bare"), headers: bearer() });
    assert.equal(res.statusCode, 200);
    const body = res.json() as DevicesBody;
    assert.deepEqual(body.devices, []);
    // Not a 404: "no such office" and "no hardware in this office" are
    // different facts with different fixes.
    assert.equal(body.officeId, "bare");
  });
});

describe("what a signed-in member reads", () => {
  it("serves one state per surviving declaration, all marked synthetic", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
    assert.equal(res.statusCode, 200);
    const body = res.json() as DevicesBody;
    assert.equal(body.source, "sim");
    assert.equal(body.synthetic, true);
    assert.ok(body.ttlSeconds > 0);
    assert.deepEqual(body.devices.map((d) => d.id), ["mic-1", "speaker-1", "desk-mounted-mic"]);
    for (const device of body.devices) assert.equal(device.synthetic, true);

    // Readings follow the declaration, not the kind: this mic declared `gain`
    // and `level`, this speaker declared neither.
    const mic = body.devices[0] as DeviceState;
    assert.equal(typeof mic.gainDb, "number");
    assert.equal(typeof mic.levelDb, "number");
    const speaker = body.devices[1] as DeviceState;
    assert.equal(speaker.levelDb, undefined);
    assert.equal(typeof speaker.volume, "number");
  });

  it("reports no hardware at all when the box has no device source", async () => {
    const app = appWith(jwt);
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
    assert.equal(res.statusCode, 200);
    const body = res.json() as DevicesBody;
    assert.equal(body.source, "none");
    // Empty rather than invented. A box nobody has configured has no hardware,
    // and that is the truth rather than a degraded picture of one.
    assert.deepEqual(body.devices, []);
  });
});

describe("the command", () => {
  it("applies one, and the next read shows it", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const before = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
    assert.equal((before.json() as DevicesBody).devices[0]?.powered, false);

    const res = await app.inject({
      method: "POST",
      url: `${url("open")}/command`,
      headers: bearer(),
      payload: { command: { deviceId: "mic-1", op: "power", value: true } },
    });
    assert.equal(res.statusCode, 200);
    assert.equal(res.json().device.powered, true);

    const then = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
    assert.equal((then.json() as DevicesBody).devices[0]?.powered, true);
  });

  it("clamps a value into the declared range rather than refusing it", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const res = await app.inject({
      method: "POST",
      url: `${url("open")}/command`,
      headers: bearer(),
      payload: { command: { deviceId: "mic-1", op: "gain", value: 400 } },
    });
    assert.equal(res.statusCode, 200);
    // 36 dB is the top of `DEVICE_RANGES.gain`. A slider reporting 400 is a
    // caller who overshot, not an attack — and the body says what the hardware
    // actually did, so the control can snap to it.
    assert.equal(res.json().device.gainDb, 36);
  });

  it("refuses a device that does not name an authored declaration, and changes nothing", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    // Deliberately the id of a declaration the pack *did* write and the server
    // dropped, because its prop is a desk. It exists in the file and it must
    // not be commandable.
    for (const deviceId of ["mic-on-a-speaker", "mic-nowhere", "mic-undisclosed", "invented"]) {
      const res = await app.inject({
        method: "POST",
        url: `${url("open")}/command`,
        headers: bearer(),
        payload: { command: { deviceId, op: "power", value: true } },
      });
      assert.equal(res.statusCode, 400, deviceId);
      assert.equal(res.json().error, "bad_request");
    }

    const read = await app.inject({ method: "GET", url: url("open"), headers: bearer() });
    const body = read.json() as DevicesBody;
    assert.deepEqual(body.devices.map((d) => d.id), ["mic-1", "speaker-1", "desk-mounted-mic"]);
    // Nothing was created and nothing was switched on by any of that.
    assert.equal(body.devices.every((d) => d.powered === false), true);
  });

  it("refuses an op the declaration never declared", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    // The speaker declares power, volume and playback. `gain` is a microphone's.
    const res = await app.inject({
      method: "POST",
      url: `${url("open")}/command`,
      headers: bearer(),
      payload: { command: { deviceId: "speaker-1", op: "gain", value: 4 } },
    });
    assert.equal(res.statusCode, 400);
  });

  it("refuses a body that is not a command", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const bodies: unknown[] = [
      {},
      { command: null },
      { command: [] },
      { command: { op: "power", value: true } },
      { command: { deviceId: "mic-1", op: "explode", value: true } },
      { command: { deviceId: "mic-1", op: "power" } },
      { command: { deviceId: "mic-1", op: "power", value: "yes" } },
      { command: { deviceId: "mic-1", op: "gain", value: true } },
    ];
    for (const payload of bodies) {
      const res = await app.inject({
        method: "POST",
        url: `${url("open")}/command`,
        headers: bearer(),
        payload: payload as never,
      });
      assert.equal(res.statusCode, 400, JSON.stringify(payload));
      assert.equal(res.json().error, "bad_request");
    }
  });

  it("refuses a body that is not JSON at all, as the caller's mistake", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const res = await app.inject({
      method: "POST",
      url: `${url("open")}/command`,
      headers: { ...bearer(), "content-type": "text/plain" },
      payload: "turn the microphone on please",
    });
    // 400 or 415 depending on which of Fastify's own checks fires first, and
    // the assertion that matters is neither of those numbers: it is that this
    // is a 4xx carrying the one error shape rather than a 500. This route was
    // the first body on the box, and until it existed the error handler
    // reported every framework refusal as a server fault.
    assert.ok(res.statusCode >= 400 && res.statusCode < 500, `status ${res.statusCode}`);
    assert.equal(res.json().error, "bad_request");
  });

  it("refuses a command body larger than a command could be", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const res = await app.inject({
      method: "POST",
      url: `${url("open")}/command`,
      headers: bearer(),
      payload: { command: { deviceId: "mic-1", op: "power", value: true }, padding: "x".repeat(4096) },
    });
    assert.equal(res.statusCode, 413);
    assert.equal(res.json().error, "bad_request");
  });

  it("refuses a command for an office that does not exist", async () => {
    const app = appWith({ ...jwt, ...sim });
    after(() => app.close());

    const res = await app.inject({
      method: "POST",
      url: `${url("no-such-thing")}/command`,
      headers: bearer(),
      payload: { command: { deviceId: "mic-1", op: "power", value: true } },
    });
    assert.equal(res.statusCode, 404);
  });

  it("refuses every command when the box has no device source", async () => {
    const app = appWith(jwt);
    after(() => app.close());

    const res = await app.inject({
      method: "POST",
      url: `${url("open")}/command`,
      headers: bearer(),
      payload: { command: { deviceId: "mic-1", op: "power", value: true } },
    });
    assert.equal(res.statusCode, 400);
  });
});

describe("health says whether asking is worth it", () => {
  it("names the device source and the served regions", async () => {
    const app = appWith(sim);
    after(() => app.close());

    const res = await app.inject({ method: "GET", url: "/api/v1/health" });
    assert.equal(res.statusCode, 200);
    const body = res.json();
    assert.equal(body.sources.devices, "sim");
    assert.ok(Array.isArray(body.regions));
    assert.ok(body.regions.length > 0);
  });

  it("says none on a box nobody configured, without calling it a demotion", async () => {
    const app = appWith({});
    after(() => app.close());

    const body = (await app.inject({ method: "GET", url: "/api/v1/health" })).json();
    assert.equal(body.sources.devices, "none");
    // The default is a choice, not a failure. A zero-config box's `degraded`
    // list stays empty, which `check-zero-config-boot.mjs` also asserts.
    assert.deepEqual(body.degraded, []);
  });

  it("demotes a source it cannot honour and says which", async () => {
    const app = appWith({ TERA_DEVICES_SOURCE: "homeassistant" });
    after(() => app.close());

    const body = (await app.inject({ method: "GET", url: "/api/v1/health" })).json();
    assert.equal(body.sources.devices, "none");
    assert.equal(
      body.degraded.filter((line: string) => line.includes("TERA_DEVICES_SOURCE")).length,
      1,
    );
  });
});
