/**
 * The simulator, on a wall clock.
 *
 * `src/devices/sim.ts` is a fixed-step state machine that reads no clock. This
 * file is the ten lines that make it answer questions asked over HTTP: one
 * simulator per office, advanced to *now* the moment somebody asks and never
 * otherwise.
 *
 * **It is the same module the browser and the arena drive.** Not a port, not a
 * server-side reimplementation — the import is `src/devices/sim.ts`. That is
 * the property that makes a bug report about a level meter reproducible and
 * makes an arena rollout describe the same studio a viewer is looking at, and
 * it is worth the one awkwardness it costs: a package under `src/` imported by
 * the server, which `media/bindings.ts` and `regions.ts` already do for exactly
 * the same reason.
 *
 * ### Nothing ticks in the background
 *
 * There is no interval here. A box nobody is looking at advances no simulation,
 * makes no outbound request and does no work at all — the third rule
 * `upstream.ts` states for weather and flights, applied to a source that
 * happens to be local. The cost is that the first request after a quiet hour
 * has an hour to catch up on, which is what `MAX_CATCHUP_STEPS` is about.
 */

import { createSimulatedDevices, type SimulatedDevices } from "../../../src/devices/sim.ts";
import type { DeviceDeclaration } from "../../../src/devices/types.ts";

/**
 * Seconds per simulated step, server-side.
 *
 * A tenth of a second, matching the arena's `fixedStepSeconds`, so a rollout
 * and a deployment are running the same physics at the same resolution. Finer
 * would buy nothing over a five-second poll; coarser would make a level meter
 * step visibly between polls.
 */
const STEP_SECONDS = 0.1;

/**
 * How far one request may advance a simulator that has been idle.
 *
 * Sixty seconds' worth. Past that the simulated clock simply jumps: catching up
 * honestly on an office nobody has opened since yesterday would be nearly a
 * million steps inside one request, to arrive at a level meter reading that
 * nobody watched accumulate and that carries no information — the state a
 * microphone converges to is not a function of how long it has been ignored.
 * Commands and settings are unaffected, because they are held state rather than
 * integrated state.
 */
const MAX_CATCHUP_STEPS = 600;

/**
 * A ceiling on how many offices are simulated at once.
 *
 * CONTRACT.md §5: memory-only, bounded state. The key space is office ids and
 * `offices/store.ts` will look up any id matching its pattern, so without a cap
 * an anonymous caller — well, a *signed-in* caller, this route takes a session —
 * could grow this map one request at a time. The least recently touched entry
 * is dropped, which loses nothing that cannot be rebuilt: a dropped simulator
 * comes back powered-off, which is where it started.
 */
const MAX_OFFICES = 16;

export interface DeviceRuntime {
  /** The simulator for one office, advanced to `nowMs`. */
  advance(officeId: string, declarations: readonly DeviceDeclaration[], nowMs: number): SimulatedDevices;
  /** How many offices are currently being simulated. For tests and for the bound. */
  size(): number;
}

interface Entry {
  simulator: SimulatedDevices;
  /** The declaration ids and capabilities this simulator was built for. */
  signature: string;
  /** Simulated time, in epoch milliseconds, that this simulator has reached. */
  clockMs: number;
  /** When it was last asked for, so the cap can drop the coldest. */
  touchedMs: number;
}

export interface DeviceRuntimeOptions {
  seed: number;
}

export function createDeviceRuntime(options: DeviceRuntimeOptions): DeviceRuntime {
  const offices = new Map<string, Entry>();

  return {
    advance(officeId, declarations, nowMs): SimulatedDevices {
      const signature = signatureOf(declarations);
      let entry = offices.get(officeId);

      // A pack that has been edited on disk is a different studio, and resuming
      // a simulator built for the old one would leave readings for devices that
      // no longer exist and none for the ones that do. Rebuilt rather than
      // patched: the state a device machine carries is a few booleans and a
      // level, and none of it is worth migrating.
      if (entry === undefined || entry.signature !== signature) {
        entry = {
          simulator: createSimulatedDevices(declarations, {
            // The office id is mixed into the seed so two studios on one box do
            // not run in lockstep — every mic in the building peaking together
            // is the tell that gives a simulation away.
            seed: (options.seed ^ hash(officeId)) | 0,
            fixedStepSeconds: STEP_SECONDS,
            epochMs: nowMs,
          }),
          signature,
          clockMs: nowMs,
          touchedMs: nowMs,
        };
        offices.set(officeId, entry);
        evict(offices);
        return entry.simulator;
      }

      const stepMs = STEP_SECONDS * 1000;
      const behind = Math.max(0, nowMs - entry.clockMs);
      const steps = Math.min(MAX_CATCHUP_STEPS, Math.floor(behind / stepMs));
      for (let i = 0; i < steps; i += 1) entry.simulator.stepFixed();
      // The clock is set to `now` whichever branch ran. Advancing it by
      // `steps * stepMs` instead would leave a simulator that had been capped
      // permanently behind, and it would try to catch up again on every
      // subsequent request — one poll's worth of work turning into a treadmill.
      entry.clockMs = nowMs;
      entry.touchedMs = nowMs;
      // Re-inserted so the map's iteration order is least-recently-touched
      // first, which is what makes `evict` drop the coldest office rather than
      // the oldest one.
      offices.delete(officeId);
      offices.set(officeId, entry);
      return entry.simulator;
    },

    size: () => offices.size,
  };
}

function evict(offices: Map<string, Entry>): void {
  while (offices.size > MAX_OFFICES) {
    const coldest = offices.keys().next();
    if (coldest.done) return;
    offices.delete(coldest.value);
  }
}

/**
 * What a simulator was built for, as a string.
 *
 * Ids and capabilities, in order — everything that changes which readings exist
 * and which commands are legal. Deliberately not the labels or the disclosure,
 * which are prose a pack author may reword without changing an instrument.
 */
function signatureOf(declarations: readonly DeviceDeclaration[]): string {
  return declarations.map((d) => `${d.id}:${d.kind}:${d.capabilities.join(",")}`).join("|");
}

/** FNV-1a, so two office ids that differ by one character seed differently. */
function hash(text: string): number {
  let value = 0x811c9dc5;
  for (let i = 0; i < text.length; i += 1) {
    value ^= text.charCodeAt(i);
    value = Math.imul(value, 0x01000193);
  }
  return value >>> 0;
}
