/**
 * Which source answers for device state, and what happens when none does.
 *
 * The shape `createWeatherService` and `createFlightsService` established: one
 * function, the source chosen by the environment, and **it never throws**.
 * `current()` always returns a body and `command()` always returns an outcome,
 * because the two routes above this have nothing sensible to do with an
 * exception and a 500 on a device panel is a studio that looks broken.
 *
 * Three rules beyond that, and the first is the one that differs from weather:
 *
 * 1. **`none` serves an empty array, not a fabrication.** The flights service
 *    falls back to a simulated plan because an empty sky over a city reads as a
 *    bug; an office with no hardware in it reads as an office with no hardware
 *    in it, which is the truth and is a perfectly good picture. A box that
 *    invented microphones nobody had configured would be making a claim about a
 *    room. `TERA_DEVICES_SOURCE=sim` is one variable away for anyone who wants
 *    the demonstration studio, and it is what the reference deployment runs.
 * 2. **Nothing is simulated until somebody asks.** No interval, no background
 *    tick; see `devices/sim.ts`.
 * 3. **The declarations come from the pack, never from the caller.**
 *    `devices/store.ts` resolves them against a `Plan` this process built, which
 *    is what makes a command checkable at all.
 * 4. **A source that reads real hardware never falls back to inventing it.**
 *    `first-party` demotes to `live: false` readings with `reachable: false`,
 *    never to `sim`. Substituting a state machine for a bridge is the one
 *    confusion `DeviceProvenance` exists to prevent, and it would be doing it in
 *    the direction that matters.
 *
 * ### Commands are memory-only and bounded
 *
 * A command mutates a simulator held in this process and nothing else. Nothing
 * is written to disk, no state outlives a restart, and the number of offices
 * simulated at once is capped (CONTRACT.md §5). That is the honest scope of
 * what this build's write surface is: a shared, resettable, obviously-simulated
 * studio — not a control system, and never a control system by accident.
 */

import { resolveDevices } from "./store.ts";
import {
  commandRefusal,
  createFirstPartySource,
  type FirstPartySource,
} from "./firstParty.ts";
import { createDeviceRuntime, type DeviceRuntime } from "./sim.ts";
import {
  normalizeDeviceCommand,
  type DeviceCommand,
  type DeviceState,
} from "../../../src/devices/types.ts";
import type { Office } from "../../../src/interiors/types.ts";
import type { Config } from "../config.ts";
import type { DevicesBody } from "../../../src/server/wire.ts";

export interface DevicesService {
  /**
   * Every device in one office, right now. Never throws; an office with no
   * declarations, or a box with no source, is an empty list and a 200.
   *
   * A promise because one source is a network call. The `none` and `sim` paths
   * resolve without awaiting anything, so nothing about their cadence changed.
   */
  current(office: Office): Promise<DevicesBody>;
  /**
   * Apply one command.
   *
   * The failure cases are collapsed into one on purpose. `no-such-device` and
   * `not-permitted-op` are different mistakes by the same caller, and the route
   * answers 400 to both — a caller who has been told *which* of its guesses was
   * wrong is a caller being helped to guess again.
   */
  command(office: Office, command: DeviceCommand): DeviceCommandOutcome;
  /** What this box will serve for an office. The route uses it for its 400s. */
  declarationCount(office: Office): number;
}

export type DeviceCommandOutcome =
  | { ok: true; device: DeviceState }
  | { ok: false; reason: string };

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

/**
 * Attribution for a source nobody but us produced.
 *
 * Empty, and it stays empty for `sim`: the readings are this repo's own
 * arithmetic and there is nobody to thank for them. Crediting anybody would be
 * the same mistake the flights service made when it credited adsb.lol for its
 * own simulator's aircraft. A `homeassistant` bridge would put the operator's
 * own attribution here, which is why the field exists on the body at all.
 */
const NO_ATTRIBUTION: string[] = [];

export function createDevicesService(config: Config, log: DevicesLog): DevicesService {
  const { source, ttlSeconds, seed } = config.devices;
  /**
   * Built only for the source that uses it, unlike the simulator runtime below.
   *
   * The runtime is built unconditionally because it costs an empty `Map`. This
   * one holds a URL and a cache in front of somebody's house, and a box that
   * never asked for it should not have constructed one.
   */
  const bridge: FirstPartySource | null =
    source === "first-party" ? createFirstPartySource(config.studio, log) : null;
  // Built even for `none`, because it costs one empty `Map` and it means the
  // two branches below differ by a single condition rather than by a structure.
  const runtime: DeviceRuntime = createDeviceRuntime({ seed });

  /**
   * Offices already complained about, so a poll every five seconds does not
   * become a log line every five seconds. Bounded by the same office-id key
   * space everything else here is, and it is only ever added to when a pack is
   * genuinely broken.
   */
  const complained = new Set<string>();

  /**
   * One line, once, for a pack that declares hardware none of which resolved.
   *
   * The single operator-facing diagnostic this service has, and it is worth
   * having: every drop in `store.ts` is silent by design — a public route must
   * not narrate a pack's mistakes — so without this a mistyped `anchor.propId`
   * produces an empty panel and no explanation anywhere.
   */
  const report = (office: Office, resolved: { declarations: readonly unknown[]; authored: number }) => {
    if (resolved.authored === 0 || resolved.declarations.length > 0) return;
    if (complained.has(office.id) || complained.size > 64) return;
    complained.add(office.id);
    log.warn(
      `devices: office "${office.id}" declares ${resolved.authored} device(s) and none of them ` +
        "resolved — check that each anchor.propId names a prop on that level whose kind is the " +
        "declaration's assetId. See server/src/devices/store.ts.",
    );
  };

  /** The shared shell of a body, so the two paths cannot disagree about it. */
  const body = (office: Office, devices: DeviceState[], observedAt: number): DevicesBody => ({
    officeId: office.id,
    devices,
    observedAt,
    source,
    // `false` on exactly one source, and it is derived rather than asserted: a
    // body is observed only if every reading in it is. An empty list on the
    // bridge is still `false` — a real bridge with nothing plugged into it is a
    // different picture from a box making it all up, which is the distinction
    // this field carries separately from the per-device one.
    synthetic: source !== "first-party",
    ttlSeconds,
    ...(NO_ATTRIBUTION.length > 0 ? { attribution: NO_ATTRIBUTION } : {}),
  });

  return {
    async current(office: Office): Promise<DevicesBody> {
      const now = Date.now();
      if (source === "none") return body(office, [], now);
      const resolved = resolveDevices(office);
      report(office, resolved);
      const { declarations } = resolved;
      if (declarations.length === 0) return body(office, [], now);

      if (bridge !== null) {
        // A snapshot the bridge has never obtained is `null`, and every device
        // then reports `reachable: false` with its readings absent. That is the
        // honest picture — the instruments are declared, nobody has answered —
        // and it is emphatically not an empty list, which would say this office
        // declares no hardware.
        const snapshot = await bridge.read();
        return body(
          office,
          declarations.map((declaration) =>
            bridge.stateFor(declaration, snapshot?.readings.get(declaration.id), now),
          ),
          now,
        );
      }

      const simulator = runtime.advance(office.id, declarations, now);
      // Restamped with the request's clock: the simulator's own `observedAt` is
      // its epoch plus its simulated elapsed time, which lags by up to a step
      // and by the whole of a catch-up cap. What a viewer wants to know is when
      // this reading was taken, and that is now.
      return body(
        office,
        simulator.current().map((state) => ({ ...state, observedAt: now })),
        now,
      );
    },

    command(office: Office, command: DeviceCommand): DeviceCommandOutcome {
      if (source === "none") return { ok: false, reason: "this deployment has no device source" };
      // The whole write surface of the first-party bridge, and it is a refusal.
      // Stated here rather than left implicit in a missing branch, because a
      // refusal that is only an absence is a refusal somebody removes by
      // accident. See `devices/firstParty.ts`.
      if (bridge !== null) return { ok: false, reason: commandRefusal() };
      const { declarations } = resolveDevices(office);
      const declaration = declarations.find((d) => d.id === command.deviceId);
      // The whole of the authorisation for a write, in two lines. The device
      // must be one this process found in the pack, and the op must be one that
      // declaration declared — checked against the *resolved* pack rather than
      // against anything in the request, which is what makes this a boundary
      // rather than a formality.
      if (declaration === undefined) return { ok: false, reason: "no such device in this office" };
      const normalized = normalizeDeviceCommand(declaration, command);
      if (normalized === null) return { ok: false, reason: "that device will not accept that command" };

      const now = Date.now();
      const simulator = runtime.advance(office.id, declarations, now);
      simulator.command(normalized);
      const device = simulator.current().find((state) => state.id === normalized.deviceId);
      // Unreachable: `normalized` names a declaration this simulator was built
      // from. Reported rather than asserted anyway — a route that threw here
      // would turn a device the pack author renamed into a 500.
      if (device === undefined) return { ok: false, reason: "no such device in this office" };
      return { ok: true, device: { ...device, observedAt: now } };
    },

    declarationCount(office: Office): number {
      return source === "none" ? 0 : resolveDevices(office).declarations.length;
    },
  };
}
