/**
 * Where device readings come from — and what to do when nowhere will say.
 *
 * The seam. Above it, the office scene and the device panel take a
 * `DeviceSource` and never learn whether a server answered. Below it there are
 * two strategies and one rule for choosing between them, and both the
 * strategies and the rule live here so that no consumer has to hold an opinion
 * about deployments.
 *
 * ### The rule
 *
 * **A studio is never dark.** If this deployment has a device source and this
 * viewer may read it, the readings come over the API. Otherwise they come from
 * `sim.ts`, running in this tab, labelled `synthetic: true` and `live: false`.
 * That is the same shape `markers()` takes to `sample.ts` and `HttpFlights`
 * takes to `SimulatedFlights`, and it is the same argument: an empty panel and
 * a working panel look identical to a broken one, and the clone-and-run case in
 * CONTRACT.md §0 is the commonest way this bundle is used.
 *
 * ### Reading is the demo; writing is the account
 *
 * `routes/devices.ts` refuses an anonymous read, and it should — a reading is
 * about a room somebody is standing in. But an anonymous visitor is the
 * audience this product is designed for, so a refusal must not produce a dead
 * instrument: it produces the local simulator, which is honest, alive and says
 * in the panel exactly what it is.
 *
 * That promise used to live in three file headers and nowhere in the code. The
 * choice is now made here, from `DeviceSourceOptions.viewerTier`, because it is
 * a fact about the *strategy* and not about the screen — a caller that has to
 * remember to pass the right `serverHasDevices` to get the documented behaviour
 * is a caller that will forget, and did.
 *
 * Commands do **not** get the same treatment across the boundary. On the API
 * strategy a refused command is a refusal, reported as `null`, and the panel
 * tells the person to sign in — it is never quietly applied to a local copy,
 * because a control that appears to work and changes nothing anybody else can
 * see is worse than one that says no. On the simulated strategy a command is
 * applied locally and openly: nothing there claims to be a real room.
 *
 * ### No timers
 *
 * Nothing here owns a `setInterval`. The simulated strategy is advanced by
 * `tick(dt)` from whatever render loop already exists — the same idiom
 * `luminaires.ts` and `FlightLayer` use — so there is no handle to leak, no
 * work done in a tab nobody is looking at, and a test can step it by hand and
 * get the same numbers every time. The API strategy's polling lives in
 * `watchDevices` in `adapters/http.ts`, which is where every other watch's
 * timer already is.
 */

import type { DevicesSourceId } from "../server/wire.ts";
import { createSimulatedDevices, type SimulatedDevices } from "./sim.ts";
import {
  deviceStateSignature,
  initialDeviceState,
  normalizeDeviceCommand,
  type DeviceCommand,
  type DeviceDeclaration,
  type DeviceState,
} from "./types.ts";

/**
 * What the caller is handed, with the two provenance facts attached.
 *
 * `live` is "a deployment answered"; `synthetic` is "nobody observed this".
 * They are independent and the interface needs both — see `DeviceFeed` in
 * `adapters/http.ts`, which carries the same pair over the wire and tabulates
 * every combination that exists.
 */
export interface DeviceReading {
  states: DeviceState[];
  live: boolean;
  synthetic: boolean;
  source: DevicesSourceId;
  attribution: string[];
}

export interface DeviceSource {
  /** The latest reading. Never null and never a promise; safe in a render loop. */
  current(): DeviceReading;
  /**
   * Advance simulated time by `dtSeconds`.
   *
   * A no-op on the API strategy, which is driven by its own poll. Call it every
   * frame; it is a handful of arithmetic per device and it is what makes the
   * meters move.
   */
  tick(dtSeconds: number): void;
  /** Ask again now. A no-op on the simulated strategy, which is always current. */
  refresh(): void;
  /**
   * Send a command. Resolves to the resulting state, or `null` for a refusal.
   *
   * `null` is the honest answer to "you are not signed in", "that device is not
   * in this office" and "that op is not one this device declared" alike. The
   * caller shows one message; distinguishing them here would be a taxonomy
   * nobody branches on.
   */
  command(command: DeviceCommand): Promise<DeviceState | null>;
  /**
   * Which seats have somebody in them, so a microphone can respond to the room.
   *
   * Only the simulated strategy uses it — a real bridge is reading real
   * hardware and does not need to be told who is at the desk. Passing an empty
   * array means "nobody", which is a different statement from never calling it
   * at all; see `setOccupancy` in `sim.ts`.
   */
  setOccupancy(seatIds: readonly string[]): void;
  /** Stop any poll and drop any late answer. Idempotent. */
  stop(): void;
}

/**
 * Just enough of `TeraClient` to read and command devices.
 *
 * A structural slice rather than the whole client, so this module does not
 * depend on the adapter's other nine methods and a test can hand in an object
 * with two functions on it. `adapters/http.ts` satisfies it by construction.
 */
export interface DeviceClient {
  watchDevices(
    officeId: string,
    onFeed: (feed: {
      value: DeviceState[];
      live: boolean;
      synthetic: boolean;
      source: DevicesSourceId;
      attribution: string[];
    }) => void,
  ): { current(): unknown; refresh(): void; stop(): void };
  commandDevice(officeId: string, command: DeviceCommand): Promise<DeviceState | null>;
}

export interface DeviceSourceOptions {
  /** What this office declared. The simulated strategy runs exactly these. */
  declarations: readonly DeviceDeclaration[];
  /** Fired whenever a reading a viewer could notice has changed. */
  onReading?: (reading: DeviceReading) => void;
  /** The API client, or `null` on a build with no server behind it. */
  client?: DeviceClient | null;
  officeId?: string;
  /**
   * Whether `/health` said this deployment has a device source at all.
   *
   * `false` means the API strategy is skipped without a request being made —
   * the same job `Feeds` does in `access.ts` for weather and markers, and for
   * the same reason: a poll against a box whose answer is structurally empty is
   * a request per TTL per tab, forever, to be told nothing.
   */
  serverHasDevices?: boolean;
  /**
   * Who is looking, so that an anonymous visitor gets a studio rather than a
   * corpse.
   *
   * **This is the fix for a shipping bug, and the decision belongs here rather
   * than at the call site.** `routes/devices.ts` refuses an anonymous read —
   * correctly; the readings describe a room somebody is standing in. Without
   * this field the API strategy was chosen for them anyway, because the
   * deployment *does* have a device source: the GET 401s, `watchDevices` maps
   * the null body to `live: false`, `apiSource` renders `atRest()`, and the
   * visitor is left looking at permanently powered-off instruments while the
   * poll backs off exponentially against a 401 it can never pass. The headers of
   * this file, of `routes/devices.ts` and of `ui/devicePanel.ts` all promise
   * that person a living simulated studio instead. They were the only three
   * places the promise existed.
   *
   * The tier union is restated rather than imported from `access.ts`, for the
   * same reason `wire.ts` restates `SimRoute`: nothing under `src/devices/`
   * imports the DOM, the network or the app's own session layer, and
   * `src/index.ts` re-exports this directory onto the package surface.
   *
   * Absent means "the caller did not say", which keeps every existing call site
   * behaving exactly as it did. `"anon"` is the only value that changes the
   * choice.
   */
  viewerTier?: "anon" | "member" | "god";
  /** The simulator's seed. Same seed, same studio, on every machine. */
  seed?: number;
  /** Seconds per simulated step. Smaller is smoother and costs arithmetic. */
  fixedStepSeconds?: number;
  /** What simulated time zero means, for `observedAt`. Defaults to now. */
  epochMs?: number;
}

/**
 * How many fixed steps one `tick` may run.
 *
 * A tab that was backgrounded for ten minutes comes back with a `dt` of six
 * hundred seconds, and catching up honestly would be six thousand steps in one
 * frame — a visible hitch to arrive at a meter reading nobody was watching
 * accumulate. The excess is dropped rather than queued: simulated time is
 * allowed to lag, because nothing downstream measures it against a clock.
 */
const MAX_STEPS_PER_TICK = 12;

const DEFAULT_FIXED_STEP_SECONDS = 0.1;
const DEFAULT_SEED = 8731;

/**
 * A source that never has anything to say.
 *
 * For an office that declares no devices — which is most offices, and every
 * pack written before devices existed. Distinct from a simulated source with an
 * empty declaration list only in that it is obviously nothing: no simulator is
 * constructed and `tick` does no work at all.
 */
export function createNullDeviceSource(): DeviceSource {
  const reading: DeviceReading = {
    states: [],
    live: false,
    // An empty studio has invented nothing, but saying `synthetic: false` would
    // read as "these zero readings were observed", which is a claim about a
    // room. Nothing here observed anything.
    synthetic: true,
    source: "none",
    attribution: [],
  };
  return {
    current: () => reading,
    tick: () => {},
    refresh: () => {},
    command: () => Promise.resolve(null),
    setOccupancy: () => {},
    stop: () => {},
  };
}

/**
 * The source for one office, choosing its own strategy.
 *
 * See the header for the rule. The choice is made once, at construction, from
 * facts the caller already has — there is no runtime failover, because a
 * strategy that silently swapped a real bridge for a simulator mid-session
 * would be the `first-party-sensor`/`simulated` confusion `DeviceProvenance`
 * exists to prevent, arriving without a word in the interface.
 *
 * What *does* change at runtime is `live`: an API strategy whose deployment
 * stops answering reports `live: false` and an empty list, exactly as
 * `watchPresence` reports a floor it has stopped hearing about. It does not
 * quietly start inventing readings instead.
 */
export function createDeviceSource(options: DeviceSourceOptions): DeviceSource {
  const declarations = options.declarations;
  if (declarations.length === 0) return createNullDeviceSource();

  // An anonymous viewer takes the simulator, whatever the deployment is set to.
  // Not a permission check — the route makes that, and a browser is not a
  // boundary — but a strategy choice made from a fact the caller already has,
  // so that a refusal produces a living instrument instead of a dead one. See
  // `viewerTier`.
  const useApi =
    options.client !== null &&
    options.client !== undefined &&
    typeof options.officeId === "string" &&
    options.officeId !== "" &&
    options.serverHasDevices !== false &&
    options.viewerTier !== "anon";

  return useApi
    ? apiSource(options.client as DeviceClient, options.officeId as string, declarations, options)
    : simulatedSource(declarations, options);
}

/**
 * Readings over the API.
 *
 * Thin on purpose: `watchDevices` already owns the poll, the back-off, the
 * hidden-tab rule and the publish-on-change comparison, and duplicating any of
 * it here would be a second place for the cadence to be wrong.
 *
 * The pre-connection reading is `initialDeviceState` for every declaration
 * rather than an empty list, so the panel draws its instruments immediately —
 * powered off, meters at the floor, marked not live. An empty list would make
 * the panel flicker into existence a poll later, and would be indistinguishable
 * from an office that declared nothing.
 */
function apiSource(
  client: DeviceClient,
  officeId: string,
  declarations: readonly DeviceDeclaration[],
  options: DeviceSourceOptions,
): DeviceSource {
  const observedAt = options.epochMs ?? Date.now();
  /**
   * The instruments, at rest, before anything has been heard — and again
   * whenever the deployment stops answering.
   *
   * A refusal must not empty the panel. An empty `states` means "this office
   * declares no hardware", which is a different sentence from "nobody will tell
   * me what the hardware is doing", and collapsing the two makes a panel
   * disappear at exactly the moment somebody is wondering why it is not
   * working. `live: false` is what says nobody answered; the readings alongside
   * it are the at-rest defaults, every one of them `synthetic: true`, which is
   * the same claim `initialDeviceState` is documented to make.
   */
  const atRest = (): DeviceState[] => declarations.map((d) => initialDeviceState(d, observedAt));

  let reading: DeviceReading = {
    states: atRest(),
    live: false,
    synthetic: true,
    source: "none",
    attribution: [],
  };

  const watch = client.watchDevices(officeId, (feed) => {
    reading = {
      states: feed.live ? feed.value : atRest(),
      live: feed.live,
      synthetic: feed.synthetic,
      source: feed.source,
      attribution: feed.attribution,
    };
    options.onReading?.(reading);
  });

  return {
    current: () => reading,
    // The server's clock, not ours. Advancing a local simulation alongside a
    // real feed would put two sets of numbers on one meter.
    tick: () => {},
    refresh: () => watch.refresh(),
    async command(command: DeviceCommand): Promise<DeviceState | null> {
      const declaration = declarations.find((d) => d.id === command.deviceId);
      if (declaration === undefined) return null;
      // Validated before it is sent as well as after it arrives. Not
      // redundancy: this is what stops a slider that has been dragged past its
      // own bounds from spending a round trip to be told no, and the server's
      // copy of the check is there because a browser is not a boundary.
      const normalized = normalizeDeviceCommand(declaration, command);
      if (normalized === null) return null;
      const state = await client.commandDevice(officeId, normalized);
      // A command that landed changes the room, so ask for the new picture
      // rather than waiting out the poll — and ask rather than patching the
      // local copy, because the server is the authority on what the state now
      // is and it may have clamped what we sent.
      if (state !== null) watch.refresh();
      return state;
    },
    setOccupancy: () => {},
    stop: () => watch.stop(),
  };
}

/**
 * Readings from the simulator in this tab.
 *
 * The anonymous visitor's studio, the offline clone's studio, and the studio a
 * self-hoster gets before they have configured anything. Everything it produces
 * is `synthetic: true` and `live: false`, which is what the panel puts in front
 * of the viewer alongside each declaration's own disclosure sentence.
 */
function simulatedSource(
  declarations: readonly DeviceDeclaration[],
  options: DeviceSourceOptions,
): DeviceSource {
  const fixedStepSeconds = options.fixedStepSeconds ?? DEFAULT_FIXED_STEP_SECONDS;
  const simulator: SimulatedDevices = createSimulatedDevices(declarations, {
    seed: options.seed ?? DEFAULT_SEED,
    fixedStepSeconds,
    epochMs: options.epochMs ?? Date.now(),
  });

  let states = simulator.current();
  let signature = deviceStateSignature(states);
  let carried = 0;
  let stopped = false;

  const read = (): DeviceReading => ({
    states,
    live: false,
    synthetic: true,
    // `"sim"` and not `"none"`: something is producing these readings and the
    // interface is entitled to name it. `"none"` is reserved for a source that
    // produces nothing, which is what `createNullDeviceSource` is.
    source: "sim",
    attribution: [],
  });
  let reading = read();

  function republish(): void {
    states = simulator.current();
    const next = deviceStateSignature(states);
    reading = read();
    // Only when something a viewer could see has moved. `observedAt` advances
    // on every step and is deliberately not in the signature, or this would
    // publish at the tick rate forever.
    if (next === signature) return;
    signature = next;
    options.onReading?.(reading);
  }

  return {
    current: () => reading,

    tick(dtSeconds: number): void {
      if (stopped || !Number.isFinite(dtSeconds) || dtSeconds <= 0) return;
      carried += dtSeconds;
      let steps = 0;
      while (carried >= fixedStepSeconds && steps < MAX_STEPS_PER_TICK) {
        simulator.stepFixed();
        carried -= fixedStepSeconds;
        steps += 1;
      }
      // Whatever is left over after the cap is dropped rather than banked; see
      // `MAX_STEPS_PER_TICK`.
      if (steps === MAX_STEPS_PER_TICK) carried = 0;
      if (steps > 0) republish();
    },

    refresh(): void {
      // Always current by construction — there is nothing to ask. Republished
      // anyway so a caller that calls `refresh()` to force a redraw gets one.
      republish();
    },

    command(command: DeviceCommand): Promise<DeviceState | null> {
      if (stopped) return Promise.resolve(null);
      const declaration = declarations.find((d) => d.id === command.deviceId);
      if (declaration === undefined) return Promise.resolve(null);
      const normalized = normalizeDeviceCommand(declaration, command);
      if (normalized === null) return Promise.resolve(null);
      simulator.command(normalized);
      republish();
      const applied = states.find((s) => s.id === normalized.deviceId) ?? null;
      // A promise even though nothing is awaited, so the two strategies are the
      // same shape to the caller and a panel does not have to know which it has.
      return Promise.resolve(applied);
    },

    setOccupancy(seatIds: readonly string[]): void {
      simulator.setOccupancy(seatIds);
    },

    stop(): void {
      stopped = true;
    },
  };
}
