/**
 * The simulator, held to the property the arena depends on.
 *
 * `studio-ops-v1` wraps **this module**, not a copy of it, and replays a
 * rollout by re-running it from a seed and a list of actions. That only works
 * if every number it produces is a pure function of (seed, declarations, steps,
 * commands) — so the assertions here are mostly equalities between two runs
 * rather than statements about any particular reading.
 *
 * The second half is the behaviour a viewer actually looks at: a level that
 * responds to who is at the desk, a mute that visibly stops it, a volume knob
 * the meter follows, and a device that never reports a reading its declaration
 * did not claim. Those are what make an instrument panel worth opening, and
 * they are what would silently rot without a test — a meter that stopped
 * responding would still be a meter.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { createSimulatedDevices } from "../../devices/sim.ts";
import { DEVICE_RANGES, type DeviceDeclaration, type DeviceState } from "../../devices/types.ts";

const disclosure = "Simulated studio hardware. Demonstration data, never presence data.";

const MIC: DeviceDeclaration = {
  id: "mic-1",
  kind: "mic",
  label: "Desk mic",
  assetId: "tera:device.mic.desk",
  anchor: { levelId: "l1", propId: "mic-prop", seatId: "desk-01" },
  capabilities: ["power", "mute", "gain", "level"],
  provenance: "simulated",
  disclosure,
};

const SPEAKER: DeviceDeclaration = {
  id: "speaker-1",
  kind: "speaker",
  label: "Monitor",
  assetId: "tera:device.speaker.desk",
  anchor: { levelId: "l1", propId: "speaker-prop" },
  capabilities: ["power", "volume", "playback", "level"],
  provenance: "simulated",
  disclosure,
};

/** A speaker with no meter, to prove a reading follows the declaration. */
const PLAIN_SPEAKER: DeviceDeclaration = {
  ...SPEAKER,
  id: "speaker-2",
  capabilities: ["power", "volume", "playback"],
};

const DECLARATIONS = [MIC, SPEAKER, PLAIN_SPEAKER];

function sim(seed = 7) {
  return createSimulatedDevices(DECLARATIONS, { seed, fixedStepSeconds: 0.1 });
}

/** Run a simulator and collect every reading, as JSON, for comparison. */
function run(simulator: ReturnType<typeof sim>, steps: number): string[] {
  const frames: string[] = [];
  for (let i = 0; i < steps; i += 1) {
    simulator.stepFixed();
    frames.push(JSON.stringify(simulator.current()));
  }
  return frames;
}

/** Average level over a stretch, which is what the eye reads off a meter. */
function meanLevel(simulator: ReturnType<typeof sim>, id: string, steps: number): number {
  let total = 0;
  for (let i = 0; i < steps; i += 1) {
    simulator.stepFixed();
    total += simulator.current().find((s) => s.id === id)?.levelDb ?? 0;
  }
  return total / steps;
}

function powerOn(simulator: ReturnType<typeof sim>, id: string): void {
  simulator.command({ deviceId: id, op: "power", value: true });
}

describe("determinism", () => {
  it("produces identical sequences from the same seed", () => {
    const a = run(sim(7), 400);
    const b = run(sim(7), 400);
    assert.deepEqual(a, b);
  });

  it("produces a different studio from a different seed", () => {
    // Powered up first, deliberately: a studio whose hardware is all switched
    // off reports the same floor whatever the seed, and a test that passed on
    // that would be asserting nothing.
    const a = sim(7);
    const b = sim(8);
    for (const simulator of [a, b]) {
      powerOn(simulator, "mic-1");
      simulator.setOccupancy(["desk-01"]);
    }
    assert.notDeepEqual(run(a, 200), run(b, 200));
  });

  it("reads no clock — stepping is the only thing that moves time", () => {
    const simulator = sim();
    const before = JSON.stringify(simulator.current());
    // Nothing here advances anything, however long the process has been alive.
    for (let i = 0; i < 5; i += 1) JSON.stringify(simulator.current());
    assert.equal(JSON.stringify(simulator.current()), before);
  });

  it("continues bit-exactly from a snapshot", () => {
    const original = sim(11);
    powerOn(original, "mic-1");
    original.setOccupancy(["desk-01"]);
    run(original, 137);

    // Through JSON, because that is how a caller will have kept it: an arena
    // trace on disk, not a live object.
    const snapshot: unknown = JSON.parse(JSON.stringify(original.snapshot()));
    const resumed = createSimulatedDevices(DECLARATIONS, { seed: 999, fixedStepSeconds: 0.1 });
    resumed.restore(snapshot);

    assert.deepEqual(run(resumed, 200), run(original, 200));
  });

  it("keeps the snapshot free of anything JSON would lose", () => {
    const simulator = sim();
    run(simulator, 5);
    const snapshot = simulator.snapshot();
    assert.deepEqual(JSON.parse(JSON.stringify(snapshot)), snapshot);
  });

  it("hands back a snapshot the caller can hold while the run continues", () => {
    const simulator = sim();
    powerOn(simulator, "mic-1");
    const snapshot = JSON.stringify(simulator.snapshot());
    run(simulator, 50);
    // The snapshot is a copy, not a window onto live state.
    assert.equal(JSON.stringify(simulator.snapshot()) === snapshot, false);
    assert.deepEqual(JSON.parse(snapshot), JSON.parse(JSON.stringify(JSON.parse(snapshot))));
  });

  it("ignores a snapshot it cannot read rather than half-applying it", () => {
    const simulator = sim();
    powerOn(simulator, "mic-1");
    run(simulator, 20);
    const before = JSON.stringify(simulator.current());
    for (const bad of [null, 42, "snapshot", {}, { v: 99 }, { v: 1, devices: "no" }]) {
      simulator.restore(bad);
      assert.equal(JSON.stringify(simulator.current()), before);
    }
  });

  it("does not let a snapshot add devices this simulator was not built for", () => {
    const simulator = sim();
    const other = createSimulatedDevices(
      [...DECLARATIONS, { ...MIC, id: "mic-from-another-pack" }],
      { seed: 7, fixedStepSeconds: 0.1 },
    );
    run(other, 10);
    simulator.restore(JSON.parse(JSON.stringify(other.snapshot())));
    assert.deepEqual(
      simulator.current().map((s) => s.id),
      ["mic-1", "speaker-1", "speaker-2"],
    );
  });

  it("does not shift the random stream when a command is issued", () => {
    // A command must not consume randomness, or a replay that issues one at a
    // different step would diverge from the trace it is replaying.
    const commanded = sim(5);
    const quiet = sim(5);
    commanded.command({ deviceId: "mic-1", op: "gain", value: 12 });
    // The same gain it already had, so nothing about the state changed either.
    assert.deepEqual(run(commanded, 100), run(quiet, 100));
  });
});

describe("what a device reports", () => {
  it("reports one reading per declared capability and no others", () => {
    const states = sim().current();
    const mic = states.find((s) => s.id === "mic-1") as DeviceState;
    assert.equal(typeof mic.gainDb, "number");
    assert.equal(typeof mic.levelDb, "number");
    assert.equal(typeof mic.muted, "boolean");
    assert.equal(mic.volume, undefined);
    assert.equal(mic.playing, undefined);

    const plain = states.find((s) => s.id === "speaker-2") as DeviceState;
    assert.equal(typeof plain.volume, "number");
    assert.equal(typeof plain.playing, "boolean");
    // Absent, not zero. `undefined` means "no such reading", and a panel draws
    // no meter for it — where a zero would draw a dead one.
    assert.equal(plain.levelDb, undefined);
  });

  it("starts powered off, at rest, and says every reading is invented", () => {
    for (const state of sim().current()) {
      assert.equal(state.powered, false);
      assert.equal(state.synthetic, true);
    }
    const mic = sim().current()[0] as DeviceState;
    assert.equal(mic.gainDb, DEVICE_RANGES.gain.initial);
    assert.equal(mic.levelDb, DEVICE_RANGES.level.min);
  });

  it("keeps every level inside the published range", () => {
    const simulator = sim();
    powerOn(simulator, "mic-1");
    powerOn(simulator, "speaker-1");
    simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
    simulator.command({ deviceId: "mic-1", op: "gain", value: DEVICE_RANGES.gain.max });
    simulator.setOccupancy(["desk-01"]);
    for (let i = 0; i < 600; i += 1) {
      simulator.stepFixed();
      for (const state of simulator.current()) {
        if (state.levelDb === undefined) continue;
        assert.ok(state.levelDb >= DEVICE_RANGES.level.min, `${state.levelDb}`);
        assert.ok(state.levelDb <= DEVICE_RANGES.level.max, `${state.levelDb}`);
      }
    }
  });
});

describe("a microphone that responds to the room", () => {
  it("meters higher with somebody at the desk than with nobody", () => {
    const busy = sim(3);
    powerOn(busy, "mic-1");
    busy.setOccupancy(["desk-01"]);

    const empty = sim(3);
    powerOn(empty, "mic-1");
    empty.setOccupancy([]);

    // Averaged over twenty seconds, because a single frame of room tone can
    // peak above a single frame of speech — that is what makes it look real.
    assert.ok(meanLevel(busy, "mic-1", 200) > meanLevel(empty, "mic-1", 200) + 8);
  });

  it("runs its own occupancy until somebody tells it otherwise", () => {
    // A deployment with no presence source — which is most of them, and every
    // anonymous viewer — still gets a studio that is alive rather than flat.
    const simulator = sim(23);
    powerOn(simulator, "mic-1");
    let peak = DEVICE_RANGES.level.min;
    for (let i = 0; i < 4000; i += 1) {
      simulator.stepFixed();
      peak = Math.max(peak, simulator.current()[0]?.levelDb ?? peak);
    }
    // Somewhere in that six and a half minutes, somebody sat down.
    assert.ok(peak > -30, `peak ${peak}`);
  });

  it("drops to the floor when muted, and comes back when unmuted", () => {
    const simulator = sim(3);
    powerOn(simulator, "mic-1");
    simulator.setOccupancy(["desk-01"]);
    assert.ok(meanLevel(simulator, "mic-1", 100) > -40);

    simulator.command({ deviceId: "mic-1", op: "mute", value: true });
    // Ten steps is a second — the release is slow on purpose, and a button that
    // took longer than that to visibly work would read as broken.
    for (let i = 0; i < 40; i += 1) simulator.stepFixed();
    assert.equal(simulator.current()[0]?.levelDb, DEVICE_RANGES.level.min);

    simulator.command({ deviceId: "mic-1", op: "mute", value: false });
    assert.ok(meanLevel(simulator, "mic-1", 100) > -40);
  });

  it("meters at the floor while powered off, whatever the room is doing", () => {
    const simulator = sim();
    simulator.setOccupancy(["desk-01"]);
    for (let i = 0; i < 200; i += 1) {
      simulator.stepFixed();
      assert.equal(simulator.current()[0]?.levelDb, DEVICE_RANGES.level.min);
    }
  });

  it("moves the meter with the gain knob", () => {
    const quiet = sim(9);
    powerOn(quiet, "mic-1");
    quiet.setOccupancy(["desk-01"]);
    quiet.command({ deviceId: "mic-1", op: "gain", value: 0 });

    const loud = sim(9);
    powerOn(loud, "mic-1");
    loud.setOccupancy(["desk-01"]);
    loud.command({ deviceId: "mic-1", op: "gain", value: 24 });

    assert.ok(meanLevel(loud, "mic-1", 200) > meanLevel(quiet, "mic-1", 200) + 10);
  });
});

describe("a speaker with something playing", () => {
  it("meters only while it is powered and playing", () => {
    const simulator = sim(4);
    powerOn(simulator, "speaker-1");
    for (let i = 0; i < 40; i += 1) simulator.stepFixed();
    assert.equal(simulator.current()[1]?.levelDb, DEVICE_RANGES.level.min);

    simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
    assert.ok(meanLevel(simulator, "speaker-1", 100) > -40);
  });

  it("follows the volume knob", () => {
    const loud = sim(4);
    powerOn(loud, "speaker-1");
    loud.command({ deviceId: "speaker-1", op: "playback", value: true });
    loud.command({ deviceId: "speaker-1", op: "volume", value: 1 });

    const quiet = sim(4);
    powerOn(quiet, "speaker-1");
    quiet.command({ deviceId: "speaker-1", op: "playback", value: true });
    quiet.command({ deviceId: "speaker-1", op: "volume", value: 0.1 });

    // 20·log10(0.1) is −20 dB, so the gap is real and it is arithmetic rather
    // than a taste: halving the knob drops the meter about 6 dB.
    assert.ok(meanLevel(loud, "speaker-1", 200) > meanLevel(quiet, "speaker-1", 200) + 12);
  });

  it("stops playing when it is switched off, because no real box does otherwise", () => {
    const simulator = sim();
    powerOn(simulator, "speaker-1");
    simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
    assert.equal(simulator.current()[1]?.playing, true);

    simulator.command({ deviceId: "speaker-1", op: "power", value: false });
    assert.equal(simulator.current()[1]?.playing, false);
  });

  it("will not start playing on a speaker that is switched off", () => {
    const simulator = sim();
    simulator.command({ deviceId: "speaker-1", op: "playback", value: true });
    assert.equal(simulator.current()[1]?.playing, false);
  });
});

describe("commands it refuses", () => {
  it("ignores an id it does not carry", () => {
    const simulator = sim();
    const before = JSON.stringify(simulator.current());
    simulator.command({ deviceId: "not-here", op: "power", value: true });
    assert.equal(JSON.stringify(simulator.current()), before);
  });

  it("ignores an op the declaration never declared", () => {
    const simulator = sim();
    // `speaker-2` declares power, volume and playback. Gain is a mic's.
    simulator.command({ deviceId: "speaker-2", op: "gain", value: 20 });
    assert.equal(simulator.current()[2]?.gainDb, undefined);
  });

  it("ignores a value of the wrong type", () => {
    const simulator = sim();
    simulator.command({ deviceId: "mic-1", op: "power", value: 1 as unknown as boolean });
    assert.equal(simulator.current()[0]?.powered, false);
    simulator.command({ deviceId: "mic-1", op: "gain", value: true as unknown as number });
    assert.equal(simulator.current()[0]?.gainDb, DEVICE_RANGES.gain.initial);
  });

  it("clamps a number into range rather than refusing it", () => {
    const simulator = sim();
    simulator.command({ deviceId: "mic-1", op: "gain", value: 9000 });
    assert.equal(simulator.current()[0]?.gainDb, DEVICE_RANGES.gain.max);
    simulator.command({ deviceId: "speaker-1", op: "volume", value: -3 });
    assert.equal(simulator.current()[1]?.volume, DEVICE_RANGES.volume.min);
  });

  it("cannot be handed a declaration list it then mutates under itself", () => {
    const declarations = [{ ...MIC }];
    const simulator = createSimulatedDevices(declarations, { seed: 1, fixedStepSeconds: 0.1 });
    declarations.length = 0;
    simulator.command({ deviceId: "mic-1", op: "power", value: true });
    assert.equal(simulator.current()[0]?.powered, true);
  });
});
