/**
 * The device contract, held to the two promises it makes to everybody else.
 *
 * `src/devices/types.ts` is imported by the office packs, by the device panel,
 * by the API and by the arena, and three of those four consume it in a context
 * where a mistake here is invisible until much later. So this file asserts the
 * properties the other four are *entitled to assume*:
 *
 *   1. **A declaration round-trips through JSON unchanged.** It is authored
 *      inside an `Office` pack, and CONTRACT.md §2 says a hand-written pack and
 *      one arriving over HTTP have to be the same thing. A `Date`, a class
 *      instance or an undefined-valued field would break that quietly — the
 *      pack would still build, and the served copy would differ from the
 *      authored one.
 *   2. **The vocabulary and the shape agree.** Every capability has a reading,
 *      every commandable op is a capability, every canonical set is legal. The
 *      failure this prevents is a kind added later whose new capability nobody
 *      wired into the reading table, which shows up as a control that renders
 *      and does nothing.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
  CANONICAL_CAPABILITIES,
  CAPABILITY_READING,
  DEVICE_CAPABILITIES,
  DEVICE_COMMAND_OPS,
  DEVICE_KINDS,
  DEVICE_PROVENANCE,
  DEVICE_RANGES,
  deviceKindOfAssetId,
  deviceRange,
  deviceStateSignature,
  hasCapability,
  initialDeviceState,
  isDeviceCapability,
  isDeviceCommandOp,
  isDeviceKind,
  isDeviceProvenance,
  normalizeDeviceCommand,
  validateDeviceDeclaration,
  type DeviceDeclaration,
  type DeviceState,
} from "../../devices/types.ts";

/** A mic as a pack author would write it, with every optional field populated. */
const DESK_MIC: DeviceDeclaration = {
  id: "hq-mic-01",
  kind: "mic",
  label: "Desk mic",
  assetId: "tera:device.mic.desk",
  anchor: {
    levelId: "l1",
    propId: "eng-desk-01",
    roomId: "engineering",
    seatId: "eng-01",
    offset: { x: 0.31, y: 0.74, z: -0.18 },
  },
  capabilities: ["power", "mute", "gain", "level"],
  provenance: "simulated",
  disclosure: "Simulated studio hardware. This is demonstration data, never presence data.",
};

/** A speaker with only the required fields, which is the other authoring shape. */
const DESK_SPEAKER: DeviceDeclaration = {
  id: "hq-speaker-01",
  kind: "speaker",
  label: "Monitor speaker",
  assetId: "tera:device.speaker.desk",
  anchor: { levelId: "l1", propId: "eng-monitor-01" },
  capabilities: ["power", "volume", "playback"],
  provenance: "simulated",
  disclosure: "Simulated playback. Nothing here is a recording of a real room.",
};

describe("a declaration is JSON, and only JSON", () => {
  for (const declaration of [DESK_MIC, DESK_SPEAKER]) {
    it(`round-trips ${declaration.id} through JSON unchanged`, () => {
      const round = JSON.parse(JSON.stringify(declaration)) as DeviceDeclaration;
      assert.deepEqual(round, declaration);
      // deepEqual is satisfied by a Date that stringifies to the same shape, so
      // the identity of the parsed value is checked too: what comes back must
      // be plain objects, arrays, strings, numbers and booleans.
      assert.equal(round.constructor, Object);
      assert.ok(Array.isArray(round.capabilities));
    });
  }

  it("survives the round trip inside a pack, which is where it actually lives", () => {
    // The real shape: a floorplan carrying a devices array, stringified whole.
    const floorplan = { id: "l1", devices: [DESK_MIC, DESK_SPEAKER] };
    assert.deepEqual(JSON.parse(JSON.stringify(floorplan)), floorplan);
  });

  it("carries no key whose value is undefined, which JSON silently drops", () => {
    // The trap this catches: `offset: undefined` deep-equals an absent `offset`
    // in JS but is not the same object after a round trip through a server.
    const walk = (value: unknown, path: string): void => {
      if (value === null || typeof value !== "object") return;
      if (Array.isArray(value)) {
        value.forEach((item, i) => walk(item, `${path}[${i}]`));
        return;
      }
      for (const [key, item] of Object.entries(value)) {
        assert.notEqual(item, undefined, `${path}.${key} is undefined`);
        walk(item, `${path}.${key}`);
      }
    };
    walk(DESK_MIC, "mic");
    walk(DESK_SPEAKER, "speaker");
  });
});

describe("the vocabulary", () => {
  it("gives every capability its own reading on the state", () => {
    // One row per capability, and no two capabilities pointing at the same
    // field — a duplicate would mean two controls fighting over one reading.
    const fields = DEVICE_CAPABILITIES.map((capability) => CAPABILITY_READING[capability]);
    assert.equal(Object.keys(CAPABILITY_READING).length, DEVICE_CAPABILITIES.length);
    assert.equal(new Set(fields).size, fields.length);
    // Every one of them is a field a real state actually carries.
    const everything = initialDeviceState(
      { ...DESK_MIC, capabilities: DEVICE_CAPABILITIES },
      0,
    );
    for (const field of fields) {
      assert.ok(field in everything, `${field} is not a field on DeviceState`);
    }
  });

  it("commands exactly the capabilities that are not read-only", () => {
    // `level` is a meter. If this ever admits it, something has grown a
    // "set the level" button that cannot do anything.
    assert.deepEqual(
      [...DEVICE_COMMAND_OPS].sort(),
      DEVICE_CAPABILITIES.filter((c) => c !== "level").sort(),
    );
    assert.equal(isDeviceCommandOp("level"), false);
    assert.equal(isDeviceCapability("level"), true);
  });

  it("keeps every canonical set inside the capability vocabulary", () => {
    for (const kind of DEVICE_KINDS) {
      const set = CANONICAL_CAPABILITIES[kind];
      assert.ok(set.length > 0, `${kind} has no canonical capabilities`);
      for (const capability of set) assert.ok(isDeviceCapability(capability));
      assert.ok(set.includes("power"), `${kind} must be switchable`);
    }
  });

  it("refuses a value that is not in the vocabulary", () => {
    assert.equal(isDeviceKind("thermostat"), false);
    assert.equal(isDeviceKind(7), false);
    assert.equal(isDeviceCapability("colour"), false);
    assert.equal(isDeviceProvenance("vibes"), false);
    for (const p of DEVICE_PROVENANCE) assert.equal(isDeviceProvenance(p), true);
  });

  it("gives every numeric reading a range with rest inside it", () => {
    for (const [name, range] of Object.entries(DEVICE_RANGES)) {
      assert.ok(range.min < range.max, `${name} range is inverted`);
      assert.ok(range.initial >= range.min && range.initial <= range.max, `${name} rests outside`);
      assert.notEqual(range.unit, "", `${name} has no unit`);
    }
  });
});

describe("reading an asset id", () => {
  it("finds the kind a device asset claims to be", () => {
    assert.equal(deviceKindOfAssetId("tera:device.mic.desk"), "mic");
    assert.equal(deviceKindOfAssetId("tera:device.speaker.desk"), "speaker");
    // A self-hoster's own hardware reads as a device without registering here.
    assert.equal(deviceKindOfAssetId("acme:device.mic.boom"), "mic");
  });

  it("says null for anything that is not device hardware", () => {
    assert.equal(deviceKindOfAssetId("tera:desk.workstation"), null);
    assert.equal(deviceKindOfAssetId("tera:device.thermostat.wall"), null);
    assert.equal(deviceKindOfAssetId("device"), null);
    assert.equal(deviceKindOfAssetId(""), null);
  });
});

describe("validating an authored declaration", () => {
  it("passes the two this build ships", () => {
    assert.deepEqual(validateDeviceDeclaration(DESK_MIC), []);
    assert.deepEqual(validateDeviceDeclaration(DESK_SPEAKER), []);
  });

  it("catches a declaration whose asset is the other instrument", () => {
    // The failure that matters: a command routed to the wrong hardware.
    const problems = validateDeviceDeclaration({
      ...DESK_MIC,
      assetId: "tera:device.speaker.desk",
    });
    assert.equal(problems.length, 1);
    assert.match(problems[0] ?? "", /declared a mic/);
  });

  it("catches a simulated device whose disclosure does not say so", () => {
    const problems = validateDeviceDeclaration({
      ...DESK_MIC,
      disclosure: "Live from the studio floor.",
    });
    assert.equal(problems.length, 1);
    assert.match(problems[0] ?? "", /does not say so/);
  });

  it("catches a device anchored to nothing, and one that can do nothing", () => {
    const floating = validateDeviceDeclaration({
      ...DESK_MIC,
      anchor: { levelId: "l1", propId: "" },
    });
    assert.equal(floating.length, 1);
    assert.match(floating[0] ?? "", /anchored to no prop/);

    const inert = validateDeviceDeclaration({ ...DESK_SPEAKER, capabilities: [] });
    assert.equal(inert.length, 1);
    assert.match(inert[0] ?? "", /no capabilities/);
  });

  it("never throws, whatever it is handed", () => {
    // Packs resolution records problems; it does not lose the building over
    // one bad device. That contract is only worth anything if this is total.
    const rubbish = {
      id: "",
      kind: "toaster",
      label: "  ",
      assetId: "",
      anchor: { levelId: "", propId: "" },
      capabilities: ["warmth"],
      provenance: "hearsay",
      disclosure: "",
    } as unknown as DeviceDeclaration;
    const problems = validateDeviceDeclaration(rubbish);
    assert.ok(problems.length >= 6, `expected a pile of problems, got ${problems.length}`);
  });
});

describe("the state a declaration implies", () => {
  it("carries a reading for each declared capability and no others", () => {
    const state = initialDeviceState(DESK_MIC, 1_770_000_000_000);
    assert.equal(state.powered, false);
    assert.equal(state.muted, false);
    assert.equal(state.gainDb, DEVICE_RANGES.gain.initial);
    assert.equal(state.levelDb, DEVICE_RANGES.level.initial);
    // A mic has no volume and no transport. `undefined` means "no such
    // reading", which is not the same as zero.
    assert.equal(state.volume, undefined);
    assert.equal(state.playing, undefined);
    assert.equal(state.observedAt, 1_770_000_000_000);
    assert.equal(state.synthetic, true);
  });

  it("agrees with CAPABILITY_READING for every kind", () => {
    for (const kind of DEVICE_KINDS) {
      const declaration: DeviceDeclaration = {
        ...DESK_MIC,
        kind,
        assetId: `tera:device.${kind}.desk`,
        capabilities: CANONICAL_CAPABILITIES[kind],
      };
      const state = initialDeviceState(declaration, 0);
      for (const capability of DEVICE_CAPABILITIES) {
        const field = CAPABILITY_READING[capability];
        const present = state[field] !== undefined;
        assert.equal(
          present,
          hasCapability(declaration, capability),
          `${kind}.${capability} → ${field}`,
        );
      }
    }
  });

  it("is JSON too, because it is what the wire carries", () => {
    const state = initialDeviceState(DESK_SPEAKER, 12);
    assert.deepEqual(JSON.parse(JSON.stringify(state)), state);
  });
});

describe("normalising a command", () => {
  it("accepts a supported op and clamps the knob into range", () => {
    const hot = normalizeDeviceCommand(DESK_MIC, { deviceId: DESK_MIC.id, op: "gain", value: 999 });
    assert.deepEqual(hot, { deviceId: DESK_MIC.id, op: "gain", value: DEVICE_RANGES.gain.max });

    const quiet = normalizeDeviceCommand(DESK_SPEAKER, {
      deviceId: DESK_SPEAKER.id,
      op: "volume",
      value: -3,
    });
    assert.deepEqual(quiet, {
      deviceId: DESK_SPEAKER.id,
      op: "volume",
      value: DEVICE_RANGES.volume.min,
    });
  });

  it("refuses an op the device does not declare", () => {
    // A speaker has no gain stage here. The API must not apply this and the
    // panel must not offer it.
    assert.equal(
      normalizeDeviceCommand(DESK_SPEAKER, { deviceId: DESK_SPEAKER.id, op: "gain", value: 3 }),
      null,
    );
    assert.equal(
      normalizeDeviceCommand(DESK_MIC, { deviceId: DESK_MIC.id, op: "volume", value: 0.5 }),
      null,
    );
  });

  it("refuses a command addressed to another device", () => {
    assert.equal(
      normalizeDeviceCommand(DESK_MIC, { deviceId: DESK_SPEAKER.id, op: "power", value: true }),
      null,
    );
  });

  it("refuses a value of the wrong type, including a missing one", () => {
    const bad = [
      { deviceId: DESK_MIC.id, op: "power" },
      { deviceId: DESK_MIC.id, op: "power", value: 1 },
      { deviceId: DESK_MIC.id, op: "gain", value: true },
      { deviceId: DESK_MIC.id, op: "gain", value: Number.NaN },
      { deviceId: DESK_MIC.id, op: "level", value: -3 },
      { deviceId: DESK_MIC.id, op: "explode", value: true },
    ];
    for (const command of bad) {
      assert.equal(
        normalizeDeviceCommand(DESK_MIC, command as never),
        null,
        `${JSON.stringify(command)} should be refused`,
      );
    }
  });

  it("hands back a fresh object rather than the caller's", () => {
    const sent = { deviceId: DESK_MIC.id, op: "mute" as const, value: true };
    const normalised = normalizeDeviceCommand(DESK_MIC, sent);
    assert.notEqual(normalised, sent);
    assert.deepEqual(normalised, sent);
  });
});

describe("the change signature", () => {
  it("ignores the clock and notices everything else", () => {
    const a: DeviceState = initialDeviceState(DESK_MIC, 1);
    const later: DeviceState = { ...a, observedAt: 9_999 };
    assert.equal(deviceStateSignature([a]), deviceStateSignature([later]));

    for (const changed of [
      { ...a, powered: true },
      { ...a, muted: true },
      { ...a, gainDb: 18 },
      { ...a, levelDb: -12 },
      { ...a, synthetic: false },
    ]) {
      assert.notEqual(
        deviceStateSignature([a]),
        deviceStateSignature([changed]),
        `a change to ${JSON.stringify(changed)} should publish`,
      );
    }
  });

  it("separates devices, so two states cannot swap unnoticed", () => {
    const mic = initialDeviceState(DESK_MIC, 1);
    const speaker = initialDeviceState(DESK_SPEAKER, 1);
    assert.notEqual(deviceStateSignature([mic, speaker]), deviceStateSignature([speaker, mic]));
  });
});

/**
 * The three additions real hardware forced, and the reason each is here.
 *
 * The contract as written could not carry a real room. `gainPct` normalised over
 * four different native scales has no honest decibel representation; a
 * microphone behind an SSH hop is sometimes unreachable and `powered: false` is
 * the wrong word for that; and the anonymous path runs the local simulator under
 * a declaration whose own disclosure says the hardware is live.
 */

/** A Blue Yeti Nano: capture level is an ALSA position on a 0–50 scale. */
const YETI: DeviceDeclaration = {
  ...DESK_MIC,
  id: "la-mic-yeti",
  ranges: { gain: { min: 0, max: 100, initial: 68, unit: "%" } },
  provenance: "first-party-sensor",
  disclosure: "Live reading from the studio's own desk microphone.",
  simulatedDisclosure: "Simulated in your browser — no live room is shared with visitors.",
};

describe("a device's own ranges", () => {
  it("falls back to the global range when a declaration names none", () => {
    assert.deepEqual(deviceRange(DESK_MIC, "gain"), DEVICE_RANGES.gain);
    assert.deepEqual(deviceRange(DESK_MIC, "volume"), DEVICE_RANGES.volume);
    assert.deepEqual(deviceRange(DESK_MIC, "level"), DEVICE_RANGES.level);
  });

  it("uses the declared range where there is one, per capability", () => {
    assert.deepEqual(deviceRange(YETI, "gain"), { min: 0, max: 100, initial: 68, unit: "%" });
    // Partial: `level` was not declared, so it keeps the global answer.
    assert.deepEqual(deviceRange(YETI, "level"), DEVICE_RANGES.level);
  });

  it("ignores a declared range that is not usable", () => {
    // A hand-edited pack is entitled to get this wrong, and a slider with NaN on
    // both ends is worse than one on the wrong scale.
    for (const broken of [
      { min: Number.NaN, max: 10, initial: 1, unit: "%" },
      { min: 10, max: 10, initial: 10, unit: "%" },
      { min: 50, max: 0, initial: 10, unit: "%" },
    ]) {
      const declaration = { ...DESK_MIC, ranges: { gain: broken } } as DeviceDeclaration;
      assert.deepEqual(deviceRange(declaration, "gain"), DEVICE_RANGES.gain);
    }
  });

  it("clamps a command into the declared range, not the global one", () => {
    // The failure this prevents: 999 clamped to +36 on a device whose scale
    // stops at 100, then written to hardware as a percent.
    assert.deepEqual(normalizeDeviceCommand(YETI, { deviceId: YETI.id, op: "gain", value: 999 }), {
      deviceId: YETI.id,
      op: "gain",
      value: 100,
    });
    assert.deepEqual(normalizeDeviceCommand(YETI, { deviceId: YETI.id, op: "gain", value: -5 }), {
      deviceId: YETI.id,
      op: "gain",
      value: 0,
    });
  });

  it("rests where the declaration says it rests", () => {
    assert.equal(initialDeviceState(YETI, 1).gainDb, 68);
    assert.equal(initialDeviceState(DESK_MIC, 1).gainDb, DEVICE_RANGES.gain.initial);
  });
});

describe("the disclosure a simulated fallback shows", () => {
  it("requires a simulatedDisclosure on anything that claims real hardware", () => {
    const { simulatedDisclosure: _omitted, ...withoutIt } = YETI;
    const problems = validateDeviceDeclaration(withoutIt as DeviceDeclaration);
    assert.equal(problems.length, 1);
    assert.match(problems[0] ?? "", /simulatedDisclosure/);
  });

  it("requires that sentence to actually say it is simulated", () => {
    const problems = validateDeviceDeclaration({
      ...YETI,
      simulatedDisclosure: "Readings shown in this browser.",
    });
    assert.equal(problems.length, 1);
    assert.match(problems[0] ?? "", /does not say it is simulated/);
  });

  it("asks nothing extra of a declaration that is simulated already", () => {
    // Every pack authored before this field existed. Making it structurally
    // mandatory would have invalidated them to fix a problem none of them have.
    assert.deepEqual(validateDeviceDeclaration(DESK_MIC), []);
    assert.deepEqual(validateDeviceDeclaration(YETI), []);
  });
});

describe("reachability in the change signature", () => {
  it("publishes when a device stops answering", () => {
    // Without this the panel would never republish: every other reading holds
    // its last value by design when a bridge goes quiet, so the signature would
    // be identical forever and a dead room would look like a still one.
    const reached: DeviceState = { ...initialDeviceState(YETI, 1), reachable: true };
    const lost: DeviceState = { ...reached, reachable: false };
    assert.notEqual(deviceStateSignature([reached]), deviceStateSignature([lost]));
  });

  it("distinguishes absent from present-and-true", () => {
    // Absent means the concept does not apply — every simulated device. Present
    // and true means somebody asked and got an answer. They are different facts.
    const silent = initialDeviceState(YETI, 1);
    const reached: DeviceState = { ...silent, reachable: true };
    assert.notEqual(deviceStateSignature([silent]), deviceStateSignature([reached]));
  });
});
