/**
 * `GET /api/v1/offices/:id/devices` and
 * `POST /api/v1/offices/:id/devices/command`.
 *
 * The read is a sibling of `/presence` and behaves exactly like it: viewer
 * first, then the office, 401 for anonymous, 404 for anything this caller may
 * not see, and never a shared cache. The reasoning is written out at length in
 * `routes/presence.ts` and is not repeated here; what follows is what is
 * *different* about devices, which is the write.
 *
 * ### Two routes, and that is the security property
 *
 * A command never rides in the read body and a read never applies one. This is
 * the first write surface in this product that changes something another viewer
 * can see, and folding it into the GET would mean a body that mutates a
 * microphone is a body a shared cache is entitled to keep and replay. That is
 * precisely the outcome the fail-closed `private, no-store` default in
 * `cache.ts` exists to prevent, and the way to not have that problem is to not
 * have that shape: reads are GETs and are never cached, writes are POSTs with a
 * body of their own.
 *
 * Neither route ever calls `publicCache`. Stated rather than merely omitted,
 * because the absence of a call is not self-evidently a decision — the same
 * note `routes/presence.ts` leaves for the same reason.
 *
 * ### What a command is checked against
 *
 * The pack, resolved by this process. `devices/store.ts` builds a `Plan` and
 * accepts a declaration only if the prop it is anchored to exists, is on the
 * level it claims, and *is* the hardware the declaration names. So a command
 * carries an id, and the id either names one of those or it does not — nothing
 * in the request describes the device, which means nothing in the request can
 * describe it wrongly. `officeHasMediaBinding()` makes the identical move for a
 * screen share.
 *
 * ### Reading is the demo; writing is the account
 *
 * An anonymous visitor gets 401 here and gets a **locally simulated studio**
 * from `src/devices/adapter.ts` instead — alive, labelled, and honest about
 * what it is. That is the anon-first posture applied to a route that genuinely
 * cannot be opened: the readings describe a room somebody is standing in, and
 * a command changes it for everybody else in there. What an account buys is the
 * shared room, not the demonstration.
 */

import type { FastifyInstance } from "fastify";
import { bundledOffice } from "../media/index.ts";
import { isDeviceCommandOp, type DeviceCommand } from "../../../src/devices/types.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type {
  DeviceCommandBody,
  DeviceCommandResultBody,
  ErrorBody,
} from "../../../src/server/wire.ts";
import type { Services } from "../services.ts";

const UNAUTHORIZED: ErrorBody = {
  error: "unauthorized",
  message: "Device state is for signed-in members.",
};

const NOT_FOUND: ErrorBody = { error: "not_found", message: "No such office." };

/**
 * The largest command body this route will read, in bytes.
 *
 * A `DeviceCommand` is three short fields and the largest legitimate one is
 * well under two hundred bytes. The cap is not about those — it is about the
 * body nobody meant to send, and it is set here rather than trusted to a global
 * because this is the only route on the box that accepts one.
 */
const MAX_COMMAND_BYTES = 2048;

export function registerDevices(app: FastifyInstance, services: Services): void {
  app.get<{ Params: { id: string } }>("/api/v1/offices/:id/devices", async (req, reply) => {
    // Viewer first, before the id is looked at. The ordering is the property
    // that stops this being an enumeration oracle — an anonymous caller gets
    // the same 401 for a real office, a private one and an invented one — and
    // `routes/presence.ts` explains why doing it the other way round is the bug.
    const viewer = await services.auth.resolve(req);
    if (!viewer.authenticated) {
      return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
    }

    const office = await officeFor(services, req.params.id);
    if (office === null) return reply.code(404).send(NOT_FOUND);

    // No `publicCache`, ever. See the header.
    return await services.devices.current(office);
  });

  app.post<{ Params: { id: string }; Body: unknown }>(
    "/api/v1/offices/:id/devices/command",
    { bodyLimit: MAX_COMMAND_BYTES },
    async (req, reply) => {
      const viewer = await services.auth.resolve(req);
      if (!viewer.authenticated) {
        return reply.code(401).header("www-authenticate", "Bearer").send(UNAUTHORIZED);
      }

      const office = await officeFor(services, req.params.id);
      if (office === null) return reply.code(404).send(NOT_FOUND);

      const command = readCommand(req.body);
      if (command === null) {
        const error: ErrorBody = { error: "bad_request", message: "Not a device command." };
        return reply.code(400).send(error);
      }

      const outcome = services.devices.command(office, command);
      if (!outcome.ok) {
        // One status and one message for every way a command can be wrong. A
        // caller told *which* of its guesses missed is a caller being helped to
        // guess again, and the honest audience for the distinction is the log.
        req.log.info({ officeId: office.id, reason: outcome.reason }, "device command refused");
        const error: ErrorBody = { error: "bad_request", message: "That command was refused." };
        return reply.code(400).send(error);
      }

      const body: DeviceCommandResultBody = {
        officeId: office.id,
        device: outcome.device,
        observedAt: outcome.device.observedAt,
      };
      return body;
    },
  );
}

/**
 * The office this request is about, or `null` if there is not one this viewer
 * may see.
 *
 * Served packs win over bundled ones, so an operator who has put their own
 * `lumbridge-hq.json` in `TERA_OFFICES_DIR` gets theirs. A bundled pack is the
 * fallback and not a leak: it is compiled into the browser bundle that made the
 * request, so its authored device list is already in the caller's hands — see
 * `bundledOffice`. Without it, every deployment that has not configured an
 * offices directory would answer 404 for the very studios it is rendering.
 *
 * Visibility is honoured the way `routes/offices.ts` honours it: a private pack
 * is 404 to anyone who is not signed in. By the time this is called the viewer
 * already is, so the check that remains is the one for a document that does not
 * exist at all.
 */
async function officeFor(services: Services, id: string): Promise<Office | null> {
  const doc = await services.offices.get(id);
  if (doc !== null) return doc.floor;
  return bundledOffice(id);
}

/**
 * One command, read out of an untrusted body.
 *
 * Shape only. Whether the device exists, whether it accepts this op and whether
 * the value is in range are all decided against the resolved pack in
 * `devices/index.ts`, which is the only place that can decide them — this
 * function's whole job is to make sure there is something of the right shape to
 * hand it.
 */
function readCommand(raw: unknown): DeviceCommand | null {
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
  const body = raw as Partial<DeviceCommandBody>;
  const command: unknown = body.command;
  if (command === null || typeof command !== "object" || Array.isArray(command)) return null;
  const c = command as Record<string, unknown>;
  if (typeof c.deviceId !== "string" || c.deviceId === "") return null;
  if (!isDeviceCommandOp(c.op)) return null;
  // A value of the wrong type is dropped rather than passed through, which
  // makes it a *missing* value — and `normalizeDeviceCommand` refuses a command
  // whose op needs one and has none. The refusal is therefore made in the one
  // place that knows which ops need what, rather than half here.
  const value = typeof c.value === "boolean" || typeof c.value === "number" ? c.value : undefined;
  return { deviceId: c.deviceId, op: c.op, ...(value === undefined ? {} : { value }) };
}
