/**
 * Which devices an office actually has, decided by the server.
 *
 * The device routes never take a client's word for what is in a room. A read
 * answers with the hardware **this** process found in the pack, and a command is
 * refused unless the id it names is one of them — which is the same move
 * `officeHasMediaBinding()` makes for a screen share, for the same reason: a
 * device id in a request body is a claim, and the only thing that can check a
 * claim about a building is the building.
 *
 * ### It asks `Plan`, and does not hold a second opinion
 *
 * The rules a device has to pass are real and they are exacting — the anchor
 * prop must exist, be on the level the declaration claims, and not be some
 * *other* instrument's hardware; the declaration must validate; the id must be
 * unique; the disclosure must say "simulated" if the provenance does. All of
 * that is implemented once, in `Plan.resolveDevice`, because it is the same
 * question the renderer asks and the answers have to agree.
 *
 * They have to agree in a specific direction that is easy to miss. `Plan`
 * deliberately *allows* a microphone anchored to a desk — `DeviceAnchor.offset`
 * exists for exactly those few centimetres — and only refuses an anchor to a
 * prop that is itself a device of another kind. A stricter copy of that rule
 * here would silently drop a legitimate self-hosted pack's devices from the API
 * while the browser drew them, which is the worst kind of disagreement: the
 * panel is populated and every command it sends is refused.
 *
 * So this file resolves one `Plan` and reads `allDevices()` off it. What it adds
 * is the two things a request path needs and a build step does not: it never
 * throws, and it is bounded.
 *
 * ### Why it cannot simply trust the pack
 *
 * An `Office` reaches this from two places: a bundled pack compiled into this
 * repo, and a JSON file in `TERA_OFFICES_DIR` that an operator wrote by hand.
 * The second is untrusted input in the ordinary sense — `Plan` is written for
 * authored TypeScript and reads a declaration's `label.trim()` without asking
 * whether it is a string, which a hand-edited file is entitled to get wrong. A
 * `TypeError` from inside a resolver would leave the route answering 500 for a
 * studio whose only fault is a typo, so the whole resolution is wrapped and a
 * pack this process cannot read has no devices. `offices/store.ts` takes the
 * same posture toward the document that carries it.
 */

import { Plan } from "../../../src/interiors/plan.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type { DeviceDeclaration } from "../../../src/devices/types.ts";

/**
 * A ceiling on how many devices one office may declare.
 *
 * Not a statement about studio size — the two shipped packs declare a handful
 * each. It is a bound on what one file can do to this process: every device is
 * a simulator entry advanced on every poll and a row in a body served to every
 * viewer, so a pack with fifty thousand microphones in it, by mistake or
 * otherwise, is a box that stops answering. Dropping the tail is visible and
 * recoverable; the alternative is not. `presence/store.ts` bounds a roster for
 * the identical reason.
 */
const MAX_DEVICES = 64;

export interface ResolvedDevices {
  /** The devices this box will serve and command, in pack order. */
  declarations: readonly DeviceDeclaration[];
  /**
   * How many the pack *tried* to declare.
   *
   * Carried so that "this office has no devices" and "this office declares
   * devices and not one of them resolved" can be told apart by the one party
   * who can fix the second — the operator, through one line in the log. Without
   * it a pack with a mistyped `propId` is indistinguishable from a pack that
   * never mentioned a microphone, and the visible symptom of both is an empty
   * panel.
   */
  authored: number;
}

interface Cached extends ResolvedDevices {
  office: Office;
}

/**
 * One entry per office, keyed on the id and validated against the object.
 *
 * `Plan` is not free — it re-resolves every wall, prop and seat — and the device
 * routes are polled every few seconds, so resolving per request would make a
 * device panel the most expensive thing on the box. The `office` field is the
 * real key: a bundled pack is one stable object for the life of the process, and
 * a pack read off disk is a fresh object every time the file is re-read, which
 * is exactly when the answer should be recomputed.
 */
const cache = new Map<string, Cached>();

/**
 * A ceiling on how many offices are remembered at once.
 *
 * "Bounded by what is on disk" is bounded by whatever a caller can name, and
 * `offices/store.ts` will look up any id matching its pattern. So the map is
 * capped and the oldest entry is dropped, which keeps this a cache rather than
 * an unbounded index a stranger can grow by asking for offices that do not
 * exist. CONTRACT.md §5.
 */
const MAX_CACHED_OFFICES = 16;

/** The devices this box will serve and command for one office. Never throws. */
export function resolveDevices(office: Office): ResolvedDevices {
  const hit = cache.get(office.id);
  if (hit !== undefined && hit.office === office) return hit;

  const resolved = resolve(office);
  if (cache.size >= MAX_CACHED_OFFICES) {
    const oldest = cache.keys().next();
    if (!oldest.done) cache.delete(oldest.value);
  }
  cache.set(office.id, { office, ...resolved });
  return resolved;
}

/** For tests, and for anything that swaps a pack under a running process. */
export function forgetResolvedDevices(): void {
  cache.clear();
}

function resolve(office: Office): ResolvedDevices {
  const authored = authoredCount(office);
  if (authored === 0) return { declarations: [], authored: 0 };

  let resolved;
  try {
    // `full` depth, because this is the server deciding what hardware exists,
    // not what a particular viewer may see. Who may *read* the state is the
    // route's decision and it is made before this is ever called. `warn: false`
    // because a pack's problems are the pack author's business and this is a
    // request path, not a build step.
    resolved = new Plan(office, { depth: "full", warn: false }).allDevices();
  } catch {
    // See the header: a hand-edited pack is entitled to be malformed, and the
    // honest answer to one this process cannot read is that it has no devices —
    // not a 500 on a studio somebody is standing in.
    return { declarations: [], authored };
  }

  const declarations = resolved.slice(0, MAX_DEVICES).map(
    (device): DeviceDeclaration => ({
      id: device.id,
      kind: device.kind,
      label: device.label,
      assetId: device.assetId,
      // The resolved coordinate is deliberately dropped. This side never renders
      // anything, and a declaration here exists to answer two questions — does
      // this device exist, and may it be asked to do this — neither of which a
      // position is part of. `anchor.seatId` survives because it is the one
      // address the simulator uses: a microphone's level responds to whether
      // anybody is at the desk it serves.
      anchor: {
        levelId: device.levelId,
        propId: device.propId,
        ...(device.roomId === undefined ? {} : { roomId: device.roomId }),
        ...(device.seatId === undefined ? {} : { seatId: device.seatId }),
      },
      capabilities: [...device.capabilities],
      provenance: device.provenance,
      disclosure: device.disclosure,
    }),
  );
  return { declarations, authored };
}

/**
 * How many devices the pack's files mention, whatever state they are in.
 *
 * Counted rather than resolved, and read as `unknown` rather than through the
 * `Floorplan` type, because the whole value of the number is that it is
 * available when the resolution produced nothing — including when the pack is
 * malformed enough that `Plan` refused it outright.
 */
function authoredCount(office: Office): number {
  const levels: unknown = office.levels;
  if (!Array.isArray(levels)) return 0;
  let count = 0;
  for (const level of levels) {
    if (level === null || typeof level !== "object") continue;
    const floorplan = (level as { floorplan?: unknown }).floorplan;
    if (floorplan === null || typeof floorplan !== "object") continue;
    const devices = (floorplan as { devices?: unknown }).devices;
    if (Array.isArray(devices)) count += devices.length;
  }
  return count;
}
