/**
 * The authored device layer, in the two studios and in `Plan`.
 *
 * A `DeviceDeclaration` is the first authored thing in this format that points
 * at another authored thing *and carries no coordinate of its own*. `Prop.seat`
 * is an address and nothing renders from it; a device's anchor is an address the
 * renderer takes a transform from, so a broken anchor is not a dangling label —
 * it is a microphone that is nowhere, or worse, a microphone somewhere it never
 * was.
 *
 * Two properties are worth the file on their own:
 *
 *   - **The anchor prop is the hardware.** Every declaration names a prop that
 *     exists on the level it claims, and that prop's asset id agrees with the
 *     device's kind. `deviceKindOfAssetId` reads the kind out of
 *     `<ns>:device.<kind>.<placement>`, so the check needs no asset registry and
 *     works for a self-hoster's `acme:device.mic.boom` for free.
 *   - **A bad device costs one device.** `Plan` drops it and records a problem.
 *     It must never throw, because a pack is 250 props and one typo should not
 *     cost a visitor the building — the same argument `plan.ts` makes about wall
 *     openings, and the reason `validateDeviceDeclaration` returns sentences
 *     instead of raising.
 */

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
  CANONICAL_CAPABILITIES,
  DEVICE_CAPABILITIES,
  DEVICE_RANGES,
  deviceKindOfAssetId,
  deviceRange,
  validateDeviceDeclaration,
  type DeviceDeclaration,
  type DeviceKind,
} from "../../devices/types.ts";
import { Plan } from "../../interiors/plan.ts";
import type { Office, Prop } from "../../interiors/types.ts";
import LUMBRIDGE_HQ from "../../offices/lumbridge-hq.ts";
import MATEO_COURT from "../../offices/mateo-court.ts";

/** The two studios. The hangar is in development and declares no hardware. */
const STUDIOS: readonly Office[] = [LUMBRIDGE_HQ, MATEO_COURT];

function declarationsOf(pack: Office): DeviceDeclaration[] {
  return pack.levels.flatMap((level) => [...(level.floorplan.devices ?? [])]);
}

function propsOfLevel(pack: Office, levelId: string): Prop[] {
  const level = pack.levels.find((entry) => entry.id === levelId);
  return [...(level?.floorplan.props ?? [])];
}

describe("both studios declare the hardware the product promises", () => {
  for (const pack of STUDIOS) {
    const declarations = declarationsOf(pack);

    it(`${pack.id} has at least one mic and at least one speaker`, () => {
      const kinds = declarations.map((device) => device.kind);
      for (const kind of ["mic", "speaker"] as DeviceKind[]) {
        assert.ok(kinds.includes(kind), `${pack.id} declares no ${kind}`);
      }
    });

    it(`${pack.id} anchors every device to real hardware on the level it names`, () => {
      assert.ok(declarations.length > 0, `${pack.id} declares no devices at all`);
      for (const device of declarations) {
        const prop = propsOfLevel(pack, device.anchor.levelId).find(
          (entry) => entry.id === device.anchor.propId,
        );
        assert.ok(
          prop,
          `${device.id} is anchored to "${device.anchor.propId}", which is not a prop on ` +
            `${device.anchor.levelId}`,
        );
        // The prop must be the hardware, not merely near it: a mic declaration
        // pointing at a desk would render nothing and command something.
        assert.equal(
          deviceKindOfAssetId(prop.kind),
          device.kind,
          `${device.id} is a ${device.kind} standing on ${prop.kind}`,
        );
        assert.equal(deviceKindOfAssetId(device.assetId), device.kind);
      }
    });

    it(`${pack.id} says where every reading comes from, in words`, () => {
      for (const device of declarations) {
        // The library check — provenance, disclosure wording, capability
        // vocabulary — restated here against the shipped packs rather than
        // against a fixture, because it is the shipped packs that get edited.
        assert.deepEqual(validateDeviceDeclaration(device), [], device.id);
        assert.ok(
          device.provenance === "simulated" || device.provenance === "first-party-sensor",
          `${device.id} has provenance "${device.provenance}"`,
        );
        if (device.provenance === "simulated") {
          assert.match(device.disclosure, /simulat/i, device.id);
          return;
        }
        // A live device says the opposite, and then has to carry a *second*
        // sentence for the path where the readings are not live after all: an
        // anonymous visitor cannot read the device route, so they get the local
        // simulator running this declaration. Without it the panel would print
        // "live, LA Studio" under a number invented a millisecond ago.
        assert.doesNotMatch(device.disclosure, /simulat/i, device.id);
        assert.match(device.simulatedDisclosure ?? "", /simulat/i, device.id);
      }
    });

    it(`${pack.id} describes every instrument in a vocabulary the system knows`, () => {
      // Not style: the panel builds its controls by walking this array and the
      // arena's observation width is the sum of them, so two studios authored
      // months apart disagreeing about what a mic can do changes the shape of an
      // RL observation without anybody editing the arena.
      //
      // The list is no longer required to *equal* `CANONICAL_CAPABILITIES`,
      // because the studio instruments now differ from it deliberately — see
      // "the LA studio's two live instruments" below. What every device still
      // owes is that each capability it claims is one the whole system knows and
      // one its own kind could plausibly have.
      for (const device of declarations) {
        assert.ok(device.capabilities.length > 0, device.id);
        assert.equal(new Set(device.capabilities).size, device.capabilities.length, device.id);
        for (const capability of device.capabilities) {
          assert.ok(
            DEVICE_CAPABILITIES.includes(capability),
            `${device.id} declares unknown capability "${capability}"`,
          );
          assert.ok(
            // `mute` is the one addition: canonical omits it on a speaker and
            // both real and simulated speakers plainly have one.
            capability === "mute" || CANONICAL_CAPABILITIES[device.kind].includes(capability),
            `${device.id} claims "${capability}", which a ${device.kind} does not have`,
          );
        }
      }
    });

    it(`${pack.id} keeps device ids unique across the building`, () => {
      const ids = declarations.map((device) => device.id);
      assert.equal(new Set(ids).size, ids.length);
    });
  }
});

describe("Plan resolves a device onto its anchor prop", () => {
  it("gives every declaration a position derived from the hardware", () => {
    for (const pack of STUDIOS) {
      const plan = new Plan(pack, { warn: false });
      const declarations = declarationsOf(pack);
      assert.equal(plan.allDevices().length, declarations.length, pack.id);
      for (const device of declarations) {
        const resolved = plan.device(device.id);
        assert.ok(resolved, `${device.id} did not resolve`);
        const prop = plan.prop(device.anchor.propId);
        assert.ok(prop);
        // No offset is authored in either studio, so the device stands exactly
        // where its hardware does — including the 0.73 m desktop the prop's own
        // `elevation` put it on. Nothing restates that height.
        assert.deepEqual(resolved.position, {
          x: prop.position.x,
          y: prop.position.y,
          z: prop.position.z,
        });
        assert.equal(resolved.rotation, prop.rotation);
        assert.equal(resolved.propId, prop.id);
      }
    }
  });

  /**
   * The LA studio binds each unit to the standing or sitting address it serves,
   * which is what lets a consumer ask whether anybody is where the mic is
   * pointed. An unknown seat would be repaired away silently, so this asserts
   * the bindings survived rather than that the field is spelled right.
   */
  it("keeps the LA studio's seat and room addresses", () => {
    const plan = new Plan(MATEO_COURT, { warn: false });
    assert.deepEqual(
      plan.allDevices().map((device) => `${device.id}@${device.roomId}/${device.seatId}`),
      [
        "la-front-mic@lobby/front-01",
        "la-front-speaker@lobby/front-01",
        // The two studio instruments carry the *upstream's* ids, because
        // `server/src/devices/firstParty.ts` binds a reading to a declaration by
        // id and nothing else. A prettier id here is a device that declares
        // itself live and is permanently unreachable.
        "mic-yeti@press/media-01",
        "speaker@press/media-02",
      ],
    );
  });

  /**
   * The offset is in the **prop's** frame, and that is the whole reason it
   * exists: turn the desk and the mic stays on the corner of it. A fixture
   * rather than a shipped pack, because neither studio needs an offset and a
   * test that only exercises the zero case does not test the rotation at all.
   */
  it("rotates an authored offset into the prop's frame", () => {
    const plan = new Plan(offsetFixture(), { warn: false });
    assert.deepEqual(plan.problems, []);
    const device = plan.device("fixture-mic");
    assert.ok(device);
    // The desk is at (4, 0, 6) turned a quarter turn clockwise about +Y, so the
    // prop's local +X points along world +Z. An offset of 0.5 along local +X
    // therefore lands 0.5 further down the page, not 0.5 to the right.
    assert.equal(round(device.position.x), 4);
    assert.equal(round(device.position.y), 0.73);
    assert.equal(round(device.position.z), 6.5);
  });
});

/**
 * The LA studio is the first pack in this repo whose readings are not invented,
 * and every one of these assertions is a refusal rather than a feature.
 *
 * The upstream's own `GET /api/la-studio/state` reports, per microphone,
 * `{ muted, gainPct, gainRaw, reachable, error, checkedAt }` — no level. A level
 * needs `POST /levels`, which records one and a half to three seconds of audio
 * per microphone to measure it. And `gainPct` is a percentage of four different
 * native mixer travels, which is not any number of decibels. So this file pins
 * the two omissions, because they are the sort of thing somebody adds back in
 * good faith to make a room feel alive.
 */
describe("the LA studio's two live instruments", () => {
  const live = declarationsOf(MATEO_COURT).filter(
    (device) => device.provenance === "first-party-sensor",
  );

  it("promotes exactly the two studio instruments, by their upstream ids", () => {
    assert.deepEqual(live.map((device) => device.id), ["mic-yeti", "speaker"]);
    // The other two are reception's, they are invented, and they stay that way.
    const simulated = declarationsOf(MATEO_COURT).filter(
      (device) => device.provenance === "simulated",
    );
    assert.deepEqual(simulated.map((device) => device.id), ["la-front-mic", "la-front-speaker"]);
  });

  it("declares no level meter on a microphone in a room with people in it", () => {
    for (const device of live.filter((entry) => entry.kind === "mic")) {
      assert.ok(!device.capabilities.includes("level"), device.id);
    }
  });

  it("states its gain in the unit the upstream actually speaks", () => {
    const mic = live.find((device) => device.kind === "mic");
    assert.ok(mic);
    // Fail-closed and checked on both sides: without a declared non-decibel
    // range the bridge deliberately emits no gain at all, because the global
    // default is decibels and 68 % of a Yeti's travel is not any number of them.
    assert.deepEqual(mic.ranges?.gain, { min: 0, max: 100, initial: 60, unit: "%" });
    assert.equal(deviceRange(mic, "gain").unit, "%");
    assert.notEqual(deviceRange(mic, "gain").unit, DEVICE_RANGES.gain.unit);
  });

  it("names the real hardware in one sentence and the simulator in another", () => {
    for (const device of live) {
      assert.match(device.disclosure, /live/i, device.id);
      assert.match(device.disclosure, /LA Studio/, device.id);
      assert.doesNotMatch(device.disclosure, /simulat/i, device.id);
      assert.match(device.simulatedDisclosure ?? "", /simulat/i, device.id);
      // Neither sentence may become a coordinate. The upstream store is centred
      // on somebody's home and the words that describe it are not ours to
      // publish; "the LA Studio" is the endpoint's own name for itself.
      assert.doesNotMatch(device.disclosure, /bedroom|bed\b|asleep|address/i, device.id);
      assert.doesNotMatch(device.simulatedDisclosure ?? "", /bedroom|asleep/i, device.id);
    }
  });
});

/**
 * SF has no hardware anywhere, and the pack says so in the sentence a viewer
 * reads rather than by omission.
 */
describe("the SF studio stays simulated and says what it is", () => {
  const declarations = declarationsOf(LUMBRIDGE_HQ);

  it("declares nothing live", () => {
    for (const device of declarations) assert.equal(device.provenance, "simulated");
  });

  it("says out loud that it is a room nobody is standing in", () => {
    for (const device of declarations) {
      assert.match(device.disclosure, /simulat/i, device.id);
      assert.match(device.disclosure, /not a room anybody is standing in/i, device.id);
      assert.match(device.disclosure, /no hardware in San Francisco/i, device.id);
    }
  });

  /**
   * The two *studio* instruments in each pack describe themselves identically,
   * so a viewer comparing SF with LA is comparing where the numbers come from
   * and nothing else. `mateo-court`'s reception desk is not a studio and keeps
   * the canonical list — including the level meter its simulator is free to
   * invent, which is the contrast the disclosure sentences are there to explain.
   */
  it("uses the same capability vocabulary as the studio that is real", () => {
    const shape = (devices: readonly DeviceDeclaration[]) =>
      devices
        .map((device) => `${device.kind}:${[...device.capabilities].sort().join("+")}`)
        .sort();
    const sf = shape(declarationsOf(LUMBRIDGE_HQ));
    const la = shape(
      declarationsOf(MATEO_COURT).filter((device) => device.provenance === "first-party-sensor"),
    );
    assert.deepEqual(sf, la);
    assert.deepEqual(sf, ["mic:gain+mute+power", "speaker:mute+playback+power+volume"]);
  });

  it("states its gain in decibels, because a simulated preamp really is decibels", () => {
    const mic = declarationsOf(LUMBRIDGE_HQ).find((device) => device.kind === "mic");
    assert.ok(mic);
    assert.equal(deviceRange(mic, "gain").unit, DEVICE_RANGES.gain.unit);
    assert.deepEqual(mic.ranges?.gain, DEVICE_RANGES.gain);
  });

  it("declares no level meter either, though nothing stops it inventing one", () => {
    for (const device of declarationsOf(LUMBRIDGE_HQ)) {
      assert.ok(!device.capabilities.includes("level"), device.id);
    }
  });
});

describe("a broken device costs one device", () => {
  const CASES: readonly [string, (device: DeviceDeclaration) => DeviceDeclaration, RegExp][] = [
    [
      "an anchor naming a prop that does not exist",
      (device) => ({ ...device, anchor: { ...device.anchor, propId: "no-such-prop" } }),
      /anchored to unknown prop/,
    ],
    [
      "an anchor on a level the declaration was not written on",
      (device) => ({ ...device, anchor: { ...device.anchor, levelId: "level-9" } }),
      /declared on level/,
    ],
    [
      "hardware of the wrong kind",
      (device) => ({ ...device, kind: "speaker", assetId: "tera:device.speaker.desk" }),
      /is a speaker anchored to tera:device\.mic\.desk/,
    ],
    [
      "a disclosure that does not say the readings are simulated",
      (device) => ({ ...device, disclosure: "Live studio hardware." }),
      /does not say so/,
    ],
  ];

  for (const [what, mutate, message] of CASES) {
    it(`drops a device with ${what}, and never throws`, () => {
      const office = offsetFixture();
      const level = office.levels[0];
      assert.ok(level);
      const original = level.floorplan.devices?.[0];
      assert.ok(original);
      level.floorplan.devices = [mutate(original)];

      const plan = new Plan(office, { warn: false });
      assert.equal(plan.allDevices().length, 0);
      assert.equal(plan.levels[0]?.devices.length, 0);
      assert.equal(plan.problems.length, 1);
      assert.equal(plan.problems[0]?.action, "dropped");
      assert.match(plan.problems[0]?.message ?? "", message);
      // And the building is still a building: the drop costs the device and
      // nothing else, which is the property that makes authored furniture safe.
      assert.equal(plan.levels[0]?.props.length, 1);
      assert.equal(plan.levels[0]?.rooms.length, 1);
    });
  }

  it("clears an unknown seat rather than dropping the device", () => {
    const office = offsetFixture();
    const level = office.levels[0];
    assert.ok(level);
    const original = level.floorplan.devices?.[0];
    assert.ok(original);
    level.floorplan.devices = [
      { ...original, anchor: { ...original.anchor, seatId: "nobody-sits-here" } },
    ];

    const plan = new Plan(office, { warn: false });
    assert.equal(plan.problems.length, 1);
    assert.equal(plan.problems[0]?.action, "repaired");
    assert.equal(plan.device("fixture-mic")?.seatId, undefined);
  });
});

/**
 * A public build takes the device away with the furniture, and says nothing.
 *
 * This is the one case where a device that does not resolve is **not** a
 * problem, and telling the two apart is the whole of the distinction: "your
 * hardware does not exist" is a typo in a pack, and "your hardware is not in
 * this build" is `PlanOptions.depth` doing exactly what it is for. Reporting the
 * second would put a line in `problems` on every public build of any pack that
 * ever marks a desk private, and `packRegression.test.ts` asserts that list is
 * empty at both depths.
 */
describe("depth takes a device away with its hardware", () => {
  it("drops a device standing on a private prop, without reporting one", () => {
    const office = offsetFixture();
    const prop = office.levels[0]?.floorplan.props?.[0];
    assert.ok(prop);
    prop.audience = "private";

    const full = new Plan(office, { warn: false });
    assert.equal(full.allDevices().length, 1);
    assert.deepEqual(full.problems, []);

    const publicBuild = new Plan(office, { depth: "public", warn: false });
    assert.equal(publicBuild.allDevices().length, 0);
    assert.equal(publicBuild.levels[0]?.devices.length, 0);
    assert.deepEqual(publicBuild.problems, []);
  });

  /**
   * And the same for a private desk bank, which is the harder half: a private
   * bank generates no props at all, so the ids it *would* have generated have to
   * be derived from the contract rather than observed.
   */
  it("drops a device standing on a private bank's desk, without reporting one", () => {
    const office = offsetFixture();
    const level = office.levels[0];
    assert.ok(level);
    level.floorplan.props = [];
    level.floorplan.deskBanks = [
      {
        id: "hidden",
        desk: "tera:desk.workstation",
        chair: "tera:seat.task-chair",
        origin: { x: 4, z: 6 },
        rotation: 0,
        columns: 2,
        rows: 1,
        pitch: 1.7,
        audience: "private",
      },
    ];
    const original = level.floorplan.devices?.[0];
    assert.ok(original);
    level.floorplan.devices = [
      { ...original, anchor: { ...original.anchor, propId: "hidden-desk-01" } },
    ];

    const publicBuild = new Plan(office, { depth: "public", warn: false });
    assert.equal(publicBuild.allDevices().length, 0);
    assert.deepEqual(publicBuild.problems, []);

    // The full build still resolves it, standing on the desk the bank generated
    // — which is also the assertion that the derived id was the right one.
    const full = new Plan(office, { warn: false });
    assert.equal(full.device("fixture-mic")?.propId, "hidden-desk-01");
  });
});

function round(value: number): number {
  return Math.round(value * 1e6) / 1e6;
}

/**
 * The smallest office that can carry a device: one room, one desk, one mic on
 * the corner of it, and a quarter turn so that a frame error is visible.
 *
 * Built fresh on every call, because half of these tests mutate it.
 */
function offsetFixture(): Office {
  const mic: DeviceDeclaration = {
    id: "fixture-mic",
    kind: "mic",
    label: "Fixture mic",
    assetId: "tera:device.mic.desk",
    anchor: {
      levelId: "level-1",
      propId: "fixture-mic-hardware",
      offset: { x: 0.5, y: 0, z: 0 },
    },
    capabilities: CANONICAL_CAPABILITIES.mic,
    provenance: "simulated",
    disclosure: "Simulated fixture hardware, never presence data.",
  };
  return {
    id: "fixture",
    name: "Fixture",
    levels: [
      {
        id: "level-1",
        name: "Ground",
        elevation: 0,
        wallHeight: 2.8,
        floorplan: {
          rooms: [
            {
              id: "room",
              name: "Room",
              outline: [
                { x: 0, z: 0 },
                { x: 0, z: 10 },
                { x: 10, z: 10 },
                { x: 10, z: 0 },
              ],
              floor: "tera:concrete.polished",
            },
          ],
          walls: [],
          props: [
            {
              id: "fixture-mic-hardware",
              kind: "tera:device.mic.desk",
              position: { x: 4, z: 6 },
              rotation: -Math.PI / 2,
              elevation: 0.73,
            },
          ],
          devices: [mic],
        },
      },
    ],
    viewpoints: [],
  };
}
