/**
 * Devices that behave like hardware without being any.
 *
 * A fixed-step, seeded state machine over a list of `DeviceDeclaration`s. Same
 * seed, same declarations, same commands, same readings — on every machine,
 * forever, with no clock read anywhere inside `stepFixed()`.
 *
 * ### Why determinism is the whole design and not a nicety
 *
 * The arena imports **this module**, not a headless copy of it. `studio-ops-v1`
 * observes a microphone's level, a speaker's programme and whether the desk in
 * front of the mic is occupied, and it has to be able to replay a rollout and
 * arrive at the identical checksum — which is only true if every number here is
 * a pure function of (seed, step, commands). So:
 *
 *   - nothing calls `Date.now()`, `Math.random()` or `performance.now()`;
 *   - `stepFixed()` advances by exactly `fixedStepSeconds` and takes no argument
 *     that could vary with a frame rate;
 *   - the random stream is per device and is advanced only by `stepFixed`, so
 *     issuing a command does not shift the sequence a replay would draw;
 *   - `snapshot()` is plain JSON data and `restore()` reproduces the remainder
 *     of a run bit for bit.
 *
 * The property that makes the arena honest is that the renderer drives this
 * exact object. A second implementation "for the trainer" would be a simulator
 * nobody can see and a picture nobody can train against.
 *
 * ### What it models, and what it refuses to
 *
 * A microphone with a level that responds to whether anybody is at the desk it
 * serves, and a speaker with an output meter that follows its volume. That is
 * enough for the two things a viewer does with this — watch a meter move, press
 * mute and see it stop — and enough for the cross-variable coupling the arena
 * needs (a hot mic at an empty desk is waste; playback under an aircraft is
 * noise).
 *
 * It does **not** invent a track title, an artist, a speaker identity or
 * anything else that would read as a fact about a real room. `DeviceState`
 * carries no field for one, deliberately: every reading here is `synthetic` and
 * the panel says so, and the line between "a meter that moves" and "Marta is
 * talking" is the line between a simulation and a claim. `DeviceProvenance` in
 * `types.ts` is the same argument at the level of the whole device.
 *
 * ### Occupancy
 *
 * Who is at the desk is an **input**, not an invention, whenever the caller
 * knows: `setOccupancy()` hands over the seats that are occupied and the mics
 * anchored to them go live. A caller that never says gets a deterministic
 * schedule drawn from the same seed, so a deployment with no presence source —
 * which is most of them, and every anonymous viewer — still shows a studio that
 * is alive rather than a row of flat meters. Both paths are deterministic; the
 * schedule is not a fallback to randomness, it is a fallback to a fixture.
 */

import {
  DEVICE_RANGES,
  hasCapability,
  normalizeDeviceCommand,
  type DeviceCapability,
  type DeviceCommand,
  type DeviceDeclaration,
  type DeviceKind,
  type DeviceState,
} from "./types.ts";

// ---- The public shape -----------------------------------------------------

export interface SimulatedDevicesOptions {
  seed: number;
  /**
   * Seconds per `stepFixed()`. The arena runs 0.1; the browser and the server
   * run whatever their tick is, and both get the same physics because every
   * rate below is expressed per second and multiplied by this.
   */
  fixedStepSeconds: number;
  /**
   * What instant simulated time zero corresponds to, in epoch milliseconds.
   *
   * `0` by default, which makes `observedAt` an elapsed-milliseconds count and
   * is what the arena wants — a wall-clock stamp in a replayed rollout would be
   * the one field that could not match. A server or a browser driving this in
   * real time passes `Date.now()` at construction, and then `observedAt` is a
   * real timestamp because the caller advances the simulation in step with the
   * clock.
   */
  epochMs?: number;
}

export interface SimulatedDevices {
  /** Every declared device's current reading, in declaration order. */
  current(): DeviceState[];
  /**
   * Apply one command, or ignore it.
   *
   * Ignored — not thrown on — for an id this simulator does not carry, an op
   * the declaration did not declare, or a value of the wrong type; the shared
   * `normalizeDeviceCommand()` makes that decision so that the browser, the API
   * and this all refuse exactly the same things. A number outside its range is
   * clamped rather than refused, which is that function's documented
   * disposition and not a second opinion held here.
   */
  command(command: DeviceCommand): void;
  /** Advance by exactly `fixedStepSeconds`. Reads no clock. */
  stepFixed(): void;
  /** Plain JSON data. Safe to `JSON.stringify`, store and hand back later. */
  snapshot(): unknown;
  /** Resume from a snapshot. A snapshot this simulator cannot read is ignored. */
  restore(state: unknown): void;
  /**
   * Which seats have somebody in them, if the caller knows.
   *
   * Additive to the signature the build spec fixed, and additive on purpose:
   * without it the mic level could not respond to the room, which is the one
   * behaviour that makes a level meter worth drawing. Calling it once switches
   * this simulator off its own occupancy schedule for good — including a call
   * with an empty array, which means "I looked, and nobody is there", not "I
   * have nothing to say".
   */
  setOccupancy(occupiedSeatIds: readonly string[]): void;
}

// ---- Level model ----------------------------------------------------------
//
// Everything here is in dBFS and every rate is per second. The numbers are
// chosen to be *watchable* rather than to be a measurement: a meter that sits
// still is indistinguishable from a broken one, and a meter that jumps the full
// scale every frame is indistinguishable from noise.

/** The floor. A device that is off, muted or silent reports exactly this. */
const FLOOR_DB = DEVICE_RANGES.level.min;

/** The gain at which the mic model is calibrated — anything else shifts it. */
const REFERENCE_GAIN_DB = DEVICE_RANGES.gain.initial;

/** An empty studio with the air handling running, at reference gain. */
const ROOM_TONE_DB: Span = { low: -56, high: -47 };

/** Somebody talking at the desk this mic serves, at reference gain. */
const SPEECH_DB: Span = { low: -27, high: -8 };

/** A speaker at full volume, playing. Scaled by volume below. */
const PROGRAMME_DB: Span = { low: -14, high: -5 };

/** How long one syllable-scale target lasts, in seconds. */
const SPEECH_PHASE: Span = { low: 0.25, high: 0.9 };
/** Room tone drifts far more slowly than speech does. */
const ROOM_PHASE: Span = { low: 1.4, high: 3.6 };
/** Programme material moves between the two. */
const PROGRAMME_PHASE: Span = { low: 0.4, high: 1.6 };

/**
 * How fast the meter rises and falls, in dB per second.
 *
 * Asymmetric, like every programme meter ever built: fast attack so a syllable
 * registers, slow release so the eye can read the peak it just produced. A
 * symmetric filter reads as a wobble rather than as a level.
 */
const ATTACK_DB_PER_SECOND = 220;
const RELEASE_DB_PER_SECOND = 34;

/**
 * How long the desk this mic serves stays occupied, and stays empty, when
 * nobody has told us. Seconds.
 *
 * Minutes rather than seconds, because this is a person at a desk rather than a
 * syllable, and because a mic whose meter came alive every four seconds would
 * read as a fault. The two spans differ: studios are empty more than they are
 * busy.
 */
const BUSY_PHASE: Span = { low: 25, high: 90 };
const IDLE_PHASE: Span = { low: 40, high: 180 };

interface Span {
  low: number;
  high: number;
}

// ---- Randomness -----------------------------------------------------------

/**
 * One 32-bit stream per device.
 *
 * Local rather than `seededRandom` from `engine/world.ts`, and that is a hard
 * requirement rather than a preference: this module is imported by
 * `src/arena/`, which may not reach three.js, the DOM or the network, and
 * `world.ts` reaches the first. It is the same mulberry32 arithmetic, stated in
 * eleven lines, so the dependency this module carries stays at zero.
 *
 * Per device rather than one shared stream so that adding a device to a pack
 * does not re-roll every other device's future — the same reason
 * `HttpFlights` draws its route phases over the whole plan before filtering.
 */
function nextRandom(state: number): { value: number; state: number } {
  let t = (state + 0x6d2b79f5) >>> 0;
  let x = Math.imul(t ^ (t >>> 15), 1 | t);
  x = (x + Math.imul(x ^ (x >>> 7), 61 | x)) ^ x;
  return { value: ((x ^ (x >>> 14)) >>> 0) / 4_294_967_296, state: t };
}

/** A 32-bit seed for one device, mixed from the run seed and the device id. */
function streamSeed(seed: number, id: string): number {
  // FNV-1a over the id, then mixed with the run seed. The same construction
  // `arena/checksum.ts` uses, for the same reason: two devices whose ids differ
  // by one character must not draw neighbouring streams.
  let hash = 0x811c9dc5 ^ (seed | 0);
  for (let i = 0; i < id.length; i += 1) {
    hash ^= id.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return hash >>> 0;
}

// ---- Per-device runtime ---------------------------------------------------

interface Runtime {
  id: string;
  kind: DeviceKind;
  capabilities: readonly DeviceCapability[];
  /** The seat this device serves, for occupancy. `""` when it serves none. */
  seatId: string;
  powered: boolean;
  muted: boolean;
  gainDb: number;
  volume: number;
  playing: boolean;
  /** The smoothed meter reading, dBFS. */
  levelDb: number;
  /** What the meter is heading for until the phase ends. */
  targetDb: number;
  /** Seconds left in the current level phase. */
  phaseLeft: number;
  /** The device's own random stream. */
  rng: number;
  /** Self-driven occupancy, used only while nobody has called `setOccupancy`. */
  busy: boolean;
  /** Seconds left in the current self-driven occupancy phase. */
  busyLeft: number;
}

/**
 * The snapshot format. Versioned, flat, and plain JSON.
 *
 * Versioned because a snapshot outlives the code that wrote it — the arena
 * stores one mid-episode and restores it later — and an unreadable snapshot has
 * to be *recognisably* unreadable rather than half-applied. `restore()` ignores
 * anything that is not this, which leaves the simulator on its own consistent
 * state instead of a mixture of two.
 */
interface Snapshot {
  v: 1;
  elapsedMs: number;
  occupancyProvided: boolean;
  occupied: string[];
  devices: Runtime[];
}

const SNAPSHOT_VERSION = 1;

export function createSimulatedDevices(
  declarations: readonly DeviceDeclaration[],
  options: SimulatedDevicesOptions,
): SimulatedDevices {
  const dt = Math.max(0, options.fixedStepSeconds);
  const epochMs = options.epochMs ?? 0;
  // Copied, so a caller mutating the array it handed in cannot change what this
  // simulator validates commands against half way through a run.
  const declared = declarations.map((d) => d);
  const byId = new Map<string, DeviceDeclaration>(declared.map((d) => [d.id, d]));

  let elapsedMs = 0;
  let occupancyProvided = false;
  let occupied = new Set<string>();
  let devices = declared.map((d) => initialRuntime(d, options.seed));

  /** Whether the desk this device serves has somebody at it, right now. */
  function isOccupied(device: Runtime): boolean {
    if (!occupancyProvided) return device.busy;
    return device.seatId !== "" && occupied.has(device.seatId);
  }

  /**
   * Where the meter is heading, and for how long.
   *
   * One draw per phase rather than one per step: a target redrawn every frame is
   * white noise, and white noise through a smoothing filter is a meter that
   * hovers around its own mean and never peaks.
   */
  function drawPhase(device: Runtime): void {
    const quiet = !device.powered;
    if (device.kind === "mic") {
      if (quiet || device.muted) {
        device.targetDb = FLOOR_DB;
        device.phaseLeft = span(device, ROOM_PHASE);
        return;
      }
      const busy = isOccupied(device);
      device.targetDb = clamp(
        span(device, busy ? SPEECH_DB : ROOM_TONE_DB) + (device.gainDb - REFERENCE_GAIN_DB),
        FLOOR_DB,
        DEVICE_RANGES.level.max,
      );
      device.phaseLeft = span(device, busy ? SPEECH_PHASE : ROOM_PHASE);
      return;
    }

    if (quiet || !device.playing) {
      device.targetDb = FLOOR_DB;
      device.phaseLeft = span(device, PROGRAMME_PHASE);
      return;
    }
    // Volume is a fraction and the meter is decibels, so the knob enters as
    // 20·log10 — which is why halving the volume drops the meter about 6 dB
    // rather than halving the number on it. Floored well above zero so a
    // speaker turned all the way down reads as silent rather than as -Infinity.
    const attenuation = 20 * Math.log10(Math.max(device.volume, 0.001));
    device.targetDb = clamp(
      span(device, PROGRAMME_DB) + attenuation,
      FLOOR_DB,
      DEVICE_RANGES.level.max,
    );
    device.phaseLeft = span(device, PROGRAMME_PHASE);
  }

  /** One draw from the device's own stream, mapped into a span. */
  function span(device: Runtime, range: Span): number {
    const drawn = nextRandom(device.rng);
    device.rng = drawn.state;
    return range.low + (range.high - range.low) * drawn.value;
  }

  function initialRuntime(declaration: DeviceDeclaration, seed: number): Runtime {
    const device: Runtime = {
      id: declaration.id,
      kind: declaration.kind,
      capabilities: [...declaration.capabilities],
      seatId: declaration.anchor.seatId ?? "",
      // Off, like everything in `initialDeviceState`. A studio whose hardware
      // powers itself on because a page was loaded is a studio making a claim.
      powered: false,
      muted: false,
      gainDb: DEVICE_RANGES.gain.initial,
      volume: DEVICE_RANGES.volume.initial,
      playing: false,
      levelDb: FLOOR_DB,
      targetDb: FLOOR_DB,
      phaseLeft: 0,
      rng: streamSeed(seed, declaration.id),
      busy: false,
      busyLeft: 0,
    };
    // Drawn immediately so that two devices do not change phase on the same
    // step for the whole run, which is what a zero initial phase would produce.
    device.busyLeft = span(device, IDLE_PHASE);
    drawPhase(device);
    return device;
  }

  return {
    current(): DeviceState[] {
      const observedAt = epochMs + elapsedMs;
      return devices.map((device) => reading(device, byId.get(device.id), observedAt));
    },

    command(command: DeviceCommand): void {
      const declaration = byId.get(command.deviceId);
      if (declaration === undefined) return;
      const normalized = normalizeDeviceCommand(declaration, command);
      if (normalized === null) return;
      const device = devices.find((d) => d.id === normalized.deviceId);
      if (device === undefined) return;

      switch (normalized.op) {
        case "power":
          device.powered = normalized.value === true;
          // A speaker that has been switched off is not playing. Leaving
          // `playing` true would make a powered-down speaker report a
          // now-playing state, which is the one combination no real box has.
          if (!device.powered) device.playing = false;
          break;
        case "mute":
          device.muted = normalized.value === true;
          break;
        case "playback":
          // Refused rather than queued on an unpowered speaker: nothing else in
          // this file turns a device on as a side effect of another command,
          // and a press that silently powered the room would be a surprise.
          if (device.powered) device.playing = normalized.value === true;
          break;
        case "gain":
          device.gainDb = normalized.value as number;
          break;
        case "volume":
          device.volume = normalized.value as number;
          break;
      }
      // The meter follows the new state from the next step, not from the next
      // phase. Without this, muting a mic leaves the meter at speech level for
      // up to a second, which reads as a button that did not work.
      device.phaseLeft = 0;
    },

    stepFixed(): void {
      elapsedMs += dt * 1000;
      for (const device of devices) {
        if (!occupancyProvided) {
          device.busyLeft -= dt;
          if (device.busyLeft <= 0) {
            device.busy = !device.busy;
            device.busyLeft = span(device, device.busy ? BUSY_PHASE : IDLE_PHASE);
          }
        }

        device.phaseLeft -= dt;
        if (device.phaseLeft <= 0) drawPhase(device);

        const rate = device.targetDb > device.levelDb ? ATTACK_DB_PER_SECOND : RELEASE_DB_PER_SECOND;
        const move = rate * dt;
        const gap = device.targetDb - device.levelDb;
        device.levelDb =
          Math.abs(gap) <= move ? device.targetDb : device.levelDb + Math.sign(gap) * move;
      }
    },

    snapshot(): unknown {
      const state: Snapshot = {
        v: SNAPSHOT_VERSION,
        elapsedMs,
        occupancyProvided,
        occupied: [...occupied],
        // Deep-copied, because a caller holding a snapshot must not be holding a
        // live view of the state this simulator is about to mutate.
        devices: devices.map((d) => ({ ...d, capabilities: [...d.capabilities] })),
      };
      return state;
    },

    restore(state: unknown): void {
      const parsed = readSnapshot(state);
      if (parsed === null) return;
      elapsedMs = parsed.elapsedMs;
      occupancyProvided = parsed.occupancyProvided;
      occupied = new Set(parsed.occupied);
      // Only devices this simulator was constructed with, and in *its* order:
      // a snapshot from a pack with an extra microphone in it must not add one
      // here, because `current()` is answered against the declarations the
      // caller validated commands against.
      devices = devices.map((device) => {
        const saved = parsed.devices.find((d) => d.id === device.id);
        return saved === undefined ? device : { ...device, ...saved, id: device.id };
      });
    },

    setOccupancy(occupiedSeatIds: readonly string[]): void {
      occupancyProvided = true;
      occupied = new Set(occupiedSeatIds);
    },
  };
}

/**
 * One runtime as a `DeviceState`, carrying only the readings its declaration
 * implies.
 *
 * A field is omitted rather than zeroed when the device does not declare the
 * capability behind it, because `undefined` and `0` mean genuinely different
 * things to every consumer: the panel renders no control for a reading that is
 * absent, and would render a dead one for a reading that is present and zero.
 */
function reading(
  device: Runtime,
  declaration: DeviceDeclaration | undefined,
  observedAt: number,
): DeviceState {
  const has = (capability: DeviceCapability): boolean =>
    declaration === undefined
      ? device.capabilities.includes(capability)
      : hasCapability(declaration, capability);

  const state: DeviceState = {
    id: device.id,
    kind: device.kind,
    powered: device.powered,
    observedAt,
    // Never anything else from this file. Everything above is invented, and the
    // one field that says so is not a flag a caller may set.
    synthetic: true,
  };
  if (has("mute")) state.muted = device.muted;
  if (has("gain")) state.gainDb = round(device.gainDb, 2);
  if (has("level")) state.levelDb = round(device.levelDb, 1);
  if (has("volume")) state.volume = round(device.volume, 3);
  if (has("playback")) state.playing = device.playing;
  return state;
}

/**
 * A snapshot, if it is one.
 *
 * Checked rather than cast, because a snapshot arrives from wherever the caller
 * kept it — a JSON file, an arena trace, a previous version of this module —
 * and a half-applied restore is worse than a refused one: the caller believes
 * it is replaying and is not. `null` means "not mine", and `restore()` leaves
 * the simulator exactly as it was.
 */
function readSnapshot(state: unknown): Snapshot | null {
  if (state === null || typeof state !== "object" || Array.isArray(state)) return null;
  const raw = state as Partial<Snapshot>;
  if (raw.v !== SNAPSHOT_VERSION) return null;
  if (typeof raw.elapsedMs !== "number" || !Number.isFinite(raw.elapsedMs)) return null;
  if (!Array.isArray(raw.devices)) return null;
  if (!Array.isArray(raw.occupied)) return null;
  return {
    v: SNAPSHOT_VERSION,
    elapsedMs: raw.elapsedMs,
    occupancyProvided: raw.occupancyProvided === true,
    occupied: raw.occupied.filter((id): id is string => typeof id === "string"),
    devices: raw.devices.filter((d): d is Runtime => d !== null && typeof d === "object"),
  };
}

function clamp(value: number, low: number, high: number): number {
  return Math.min(high, Math.max(low, value));
}

/**
 * Rounded on the way out, never in the running state.
 *
 * The meter is smoothed at full precision and reported at a tenth of a decibel,
 * which is both what a meter is read to and what `deviceStateSignature` hashes
 * — so a reading that has not visibly changed does not republish, and a run
 * that is bit-identical internally stays bit-identical on the wire.
 */
function round(value: number, places: number): number {
  const scale = 10 ** places;
  return Math.round(value * scale) / scale;
}
