import assert from "node:assert/strict";
import { describe, it } from "node:test";
import * as THREE from "three";
import { MaterialRegistry } from "../../assets/materials.ts";
import { kit } from "../../assets/kit.ts";
import "../../assets/office/index.ts";
import { createOfficeExterior, type OfficeExterior } from "../../engine/officeExterior.ts";
import {
  FRONTIER_VALLEY_SITE,
  LUMBRIDGE_HQ_SITE,
  MATEO_COURT_SITE,
} from "../../offices/sites.ts";
import type { ExteriorArrival, OfficeSite } from "../../interiors/types.ts";
import { PARK_JITTER, arrivalGroundFor } from "../../transport/exteriorVehicle.ts";
import {
  createSimulatedVehicleTelemetry,
  type VehicleTelemetryState,
} from "../../transport/vehicleTelemetry.ts";

const SITES: readonly (readonly [string, OfficeSite])[] = [
  ["lumbridge-hq", LUMBRIDGE_HQ_SITE],
  ["frontier-valley", FRONTIER_VALLEY_SITE],
  ["mateo-court", MATEO_COURT_SITE],
];

/**
 * The sites whose arrival storey is actually on the ground, and so get an apron.
 *
 * Selected by the rule rather than listed by id, on purpose: an id list here
 * would pass just as happily against a `createOfficeExterior` that hard-coded
 * `lumbridge-hq`, which is the bug class this repo already hit once when a door
 * marker was gated on `id === "sf"`.
 */
const GROUNDED = SITES.filter(([, site]) => arrivalGroundFor(site.elevation) === "ground");
const ELEVATED = SITES.filter(([, site]) => arrivalGroundFor(site.elevation) === "air");

/** A deterministic generator, so a failure is reproducible from the seed alone. */
function seeded(seed: number): () => number {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = Math.imul(a ^ (a >>> 15), 1 | a);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

function arrivalOf(site: OfficeSite): ExteriorArrival {
  assert.ok(site.arrival, "every shipped site authors an arrival anchor");
  return site.arrival;
}

function build(
  site: OfficeSite,
  overrides: { rand?: () => number; detail?: "corridor" | "follow" } = {},
): { exterior: OfficeExterior; materials: MaterialRegistry } {
  const materials = new MaterialRegistry({ quality: "low" });
  const exterior = createOfficeExterior({
    site,
    arrival: arrivalOf(site),
    assets: kit,
    materials,
    rand: overrides.rand ?? seeded(1981),
    detail: overrides.detail ?? "corridor",
  });
  exterior.object.updateMatrixWorld(true);
  return { exterior, materials };
}

function vehicleRoots(root: THREE.Object3D): THREE.Object3D[] {
  const found: THREE.Object3D[] = [];
  root.traverse((object) => {
    if (object.userData.vehicleModel === "model-x") found.push(object);
  });
  return found;
}

function meshes(root: THREE.Object3D): THREE.Mesh[] {
  const found: THREE.Mesh[] = [];
  root.traverse((object) => {
    if (object instanceof THREE.Mesh) found.push(object);
  });
  return found;
}

function stateOf(overrides: Partial<VehicleTelemetryState> = {}): VehicleTelemetryState {
  const source = createSimulatedVehicleTelemetry({
    seed: 5, fixedStepSeconds: 0.1, ambientC: 24,
  });
  return { ...source.current(), ...overrides };
}

/** Signed smallest difference between two angles, radians. */
function angleDelta(a: number, b: number): number {
  return Math.abs(Math.atan2(Math.sin(a - b), Math.cos(a - b)));
}

describe("office exterior placement", () => {
  for (const [id, site] of GROUNDED) {
    it(`stands one Model X on ${id}'s authored arrival anchor`, () => {
      const { exterior } = build(site);
      const arrival = arrivalOf(site);

      const cars = vehicleRoots(exterior.object);
      assert.equal(cars.length, 1, "exactly one vehicle on the apron");
      const car = cars[0];
      assert.ok(car);

      const position = car.getWorldPosition(new THREE.Vector3());
      assert.ok(
        Math.hypot(position.x - arrival.position.x, position.z - arrival.position.z) <= 0.5,
        `${id}: car at ${position.x}, ${position.z} against anchor ` +
          `${arrival.position.x}, ${arrival.position.z}`,
      );

      const quaternion = car.getWorldQuaternion(new THREE.Quaternion());
      const yaw = new THREE.Euler().setFromQuaternion(quaternion, "YXZ").y;
      assert.ok(
        angleDelta(yaw, arrival.rotation) <= (2 * Math.PI) / 180,
        `${id}: yaw ${yaw} against anchor ${arrival.rotation}`,
      );

      exterior.dispose();
    });
  }

  it("holds the anchor for any generator, including an unseeded one", () => {
    // The parking jitter is what makes a car look parked rather than placed, and
    // it is the one thing between the anchor and the assertion above. Bounding
    // it in `exteriorVehicle.ts` rather than trusting a particular seed is what
    // lets a caller hand this `Math.random` without breaking the contract.
    assert.ok(Math.hypot(PARK_JITTER.lateralM, PARK_JITTER.longitudinalM) < 0.5);
    assert.ok(PARK_JITTER.yawRad < (2 * Math.PI) / 180);
    const arrival = arrivalOf(MATEO_COURT_SITE);
    for (let i = 0; i < 200; i += 1) {
      const { exterior } = build(MATEO_COURT_SITE, { rand: Math.random });
      const car = vehicleRoots(exterior.object)[0];
      assert.ok(car);
      const position = car.getWorldPosition(new THREE.Vector3());
      assert.ok(
        Math.hypot(position.x - arrival.position.x, position.z - arrival.position.z) <= 0.5,
      );
      exterior.dispose();
    }
  });

  it("builds nothing at all for a site whose arrival storey is not on ground", () => {
    // The defect this is here for renders, typechecks and passes a budget: an
    // apron built at `lumbridge-hq`'s floor is a paved pad and a Model X 188 m
    // up, in open sky beside the studio's west wall, and from the arrival
    // viewpoint it reads as a car standing on top of a wall. See
    // `ArrivalGround` for why the answer is no apron rather than a lower kerb.
    assert.ok(ELEVATED.length > 0, "a shipped site is off the ground; keep testing it");
    for (const [id, site] of ELEVATED) {
      const { exterior } = build(site);
      assert.equal(meshes(exterior.object).length, 0, `${id} drew geometry in mid-air`);
      assert.equal(vehicleRoots(exterior.object).length, 0, `${id} parked a car in the sky`);
      exterior.dispose();
    }
  });

  it("stays inert rather than throwing when telemetry arrives anyway", () => {
    // The caller does not know about architecture and should not have to: it
    // adds the object to the level group and publishes telemetry into it every
    // time a feed ticks. See the note on the early return in `officeExterior.ts`.
    const { exterior } = build(LUMBRIDGE_HQ_SITE);
    exterior.apply(stateOf({ pluggedIn: true, socPct: 40, locked: false }));
    exterior.apply(stateOf({ pluggedIn: false, socPct: 100, locked: true }));
    assert.equal(exterior.object.children.length, 0);
    exterior.dispose();
  });

  it("still gives a street-level site its kerb", () => {
    const street = build(MATEO_COURT_SITE);
    const kerb = meshes(street.exterior.object).find((mesh) => mesh.name.includes("skirting"));
    assert.ok(kerb, "the apron has a kerb");
    kerb.geometry.computeBoundingBox();
    const box = kerb.geometry.boundingBox;
    assert.ok(box);
    // A kerb you could trip over, not a painted line: this is the height that
    // makes the pad read as pavement rather than as a decal on the ground.
    assert.ok(box.max.y - box.min.y > 0.1, `kerb ${box.max.y - box.min.y} m high`);
    street.exterior.dispose();
  });

  it("puts the car on top of the paving rather than through it", () => {
    const { exterior } = build(MATEO_COURT_SITE);
    const car = vehicleRoots(exterior.object)[0];
    assert.ok(car);
    const wheelContact = car.getWorldPosition(new THREE.Vector3()).y;
    const pad = meshes(exterior.object).find((mesh) => mesh.name.includes("paving"));
    assert.ok(pad);
    const box = new THREE.Box3().setFromObject(pad);
    // The asset's origin is on the tyre contact plane, so it sits exactly on the
    // pad's top face — a millimetre of tolerance for the merged geometry's own
    // line thickness and nothing more.
    assert.ok(Math.abs(wheelContact - box.max.y) < 0.006, `${wheelContact} vs ${box.max.y}`);
    exterior.dispose();
  });

  it("constructs no light, because Atmosphere owns lighting (CONTRACT §4)", () => {
    for (const [, site] of SITES) {
      const { exterior } = build(site);
      exterior.object.traverse((object) => {
        assert.ok(
          !(object instanceof THREE.Light),
          `${object.name} is a light and this layer may not create one`,
        );
      });
      exterior.dispose();
    }
  });
});

describe("office exterior telemetry", () => {
  it("shows a cable only while the car is plugged in", () => {
    const { exterior } = build(MATEO_COURT_SITE);
    const cable = exterior.object.getObjectByName("office-exterior:cable");
    assert.ok(cable);
    exterior.apply(stateOf({ pluggedIn: false }));
    assert.equal(cable.visible, false);
    exterior.apply(stateOf({ pluggedIn: true }));
    assert.equal(cable.visible, true);
    exterior.dispose();
  });

  it("changes the charge indicator between plugged, charging and full", () => {
    const { exterior } = build(MATEO_COURT_SITE);
    const lamp = exterior.object.getObjectByName("office-exterior:lamp-charge") as THREE.Mesh;
    assert.ok(lamp);

    exterior.apply(stateOf({ pluggedIn: false, socPct: 50 }));
    const unplugged = lamp.material;
    exterior.apply(stateOf({ pluggedIn: true, socPct: 50 }));
    const charging = lamp.material;
    exterior.apply(stateOf({ pluggedIn: true, socPct: 100 }));
    const full = lamp.material;

    assert.notEqual(unplugged, charging);
    assert.notEqual(charging, full);
    // The flap on the car reads the same state as the post does, so a viewer who
    // can only see one side of the car still knows.
    const port = exterior.object.getObjectByName("office-exterior:charge-port") as THREE.Mesh;
    assert.ok(port);
    assert.equal(port.material, full);
    exterior.dispose();
  });

  it("fills the charge bar in proportion to the pack", () => {
    const { exterior } = build(MATEO_COURT_SITE);
    const bar = exterior.object.getObjectByName("office-exterior:charge-bar");
    assert.ok(bar);
    exterior.apply(stateOf({ socPct: 20 }));
    const low = bar.scale.y;
    exterior.apply(stateOf({ socPct: 90 }));
    assert.ok(bar.scale.y > low);
    exterior.apply(stateOf({ socPct: 0 }));
    assert.equal(bar.visible, false);
    exterior.dispose();
  });

  it("lights the cabin when the car is unlocked and dims it when it is not", () => {
    const { exterior } = build(MATEO_COURT_SITE);
    const glow = exterior.object.getObjectByName("office-exterior:cabin-glow") as THREE.Mesh;
    assert.ok(glow);
    const material = glow.material as THREE.MeshStandardMaterial;

    exterior.apply(stateOf({ locked: true, climateOn: false }));
    assert.equal(glow.visible, false);
    assert.equal(material.emissiveIntensity, 0);

    exterior.apply(stateOf({ locked: true, climateOn: true }));
    const preconditioning = material.emissiveIntensity;
    assert.ok(preconditioning > 0);

    exterior.apply(stateOf({ locked: false, climateOn: false }));
    assert.equal(glow.visible, true);
    assert.ok(material.emissiveIntensity > preconditioning);
    exterior.dispose();
  });

  it("mints a bounded number of indicator materials over a whole charge cycle", () => {
    // The whole point of quantising an indicator's brightness. An indicator in
    // this repo changes state by changing *material* — `materials.tinted` reaches
    // both `color` and `emissive` — and that registry cache is keyed on the
    // colour and never evicts. A charge lamp whose brightness tracked the state
    // of charge continuously would mint one material per step and hold it
    // forever, so five minutes of charging would be five minutes of leak.
    const { exterior } = build(MATEO_COURT_SITE);
    const indicators = [
      "office-exterior:lamp-charge",
      "office-exterior:lamp-climate",
      "office-exterior:lamp-lock",
      "office-exterior:charge-port",
    ].map((name) => exterior.object.getObjectByName(name) as THREE.Mesh);
    for (const mesh of indicators) assert.ok(mesh);

    const seen = new Set<THREE.Material | THREE.Material[]>();
    const source = createSimulatedVehicleTelemetry({
      seed: 3, fixedStepSeconds: 0.1, ambientC: 38, initialSocPct: 5,
    });
    source.command({ op: "charge", value: true });
    source.command({ op: "climate", value: true });
    for (let i = 0; i < 3_000; i += 1) {
      source.stepFixed();
      exterior.apply(source.current());
      for (const mesh of indicators) seen.add(mesh.material);
    }
    assert.ok(
      seen.size <= 16,
      `${seen.size} distinct indicator materials over 3,000 applies`,
    );
    // And the run genuinely moved through states rather than sitting still,
    // which is what makes the bound above worth anything.
    assert.ok(seen.size >= 3, `${seen.size} materials means nothing changed`);
    exterior.dispose();
  });
});

describe("office exterior resources", () => {
  it("frees every geometry and every material it made, and nothing it borrowed", () => {
    // Prototype patches rather than post-hoc inspection: three.js has no
    // "was this disposed" flag, so the only honest way to assert it is to
    // record the calls.
    const disposedGeometries = new Set<THREE.BufferGeometry>();
    const disposedMaterials = new Set<THREE.Material>();
    const geometryDispose = THREE.BufferGeometry.prototype.dispose;
    const materialDispose = THREE.Material.prototype.dispose;
    THREE.BufferGeometry.prototype.dispose = function patched(this: THREE.BufferGeometry) {
      disposedGeometries.add(this);
      return geometryDispose.call(this);
    };
    THREE.Material.prototype.dispose = function patched(this: THREE.Material) {
      disposedMaterials.add(this);
      return materialDispose.call(this);
    };

    try {
      const { exterior, materials } = build(MATEO_COURT_SITE, { detail: "follow" });
      const subtree = meshes(exterior.object);
      assert.ok(subtree.length > 10);
      const geometries = new Set(subtree.map((mesh) => mesh.geometry));
      const subtreeMaterials = new Set(
        subtree
          .map((mesh) => mesh.material)
          .filter((material): material is THREE.Material => material instanceof THREE.Material),
      );
      // The registry materials this layer borrowed. They must survive: the
      // office's own floor is drawn with `polishedConcrete` too, and freeing it
      // out here would blank the building the apron stands outside of.
      const borrowed = [
        materials.get("polishedConcrete"),
        materials.get("skirting"),
        materials.get("deviceShell"),
        materials.get("metalTrim"),
      ];

      exterior.dispose();

      for (const geometry of geometries) {
        assert.ok(disposedGeometries.has(geometry), `undisposed geometry: ${geometry.name}`);
      }
      // Everything this layer minted carries a name that says who made it: the
      // vehicle asset's own materials and the one cabin-glow material.
      const owned = [...subtreeMaterials].filter(
        (material) =>
          material.name.startsWith("model-x.") || material.name.startsWith("office-exterior."),
      );
      assert.ok(owned.length >= 8, `expected the vehicle's own skin, saw ${owned.length}`);
      for (const material of owned) {
        assert.ok(disposedMaterials.has(material), `undisposed material: ${material.name}`);
      }
      for (const material of borrowed) {
        assert.ok(!disposedMaterials.has(material), `freed a borrowed ${material.name}`);
      }
      assert.equal(exterior.object.children.length, 0);
    } finally {
      THREE.BufferGeometry.prototype.dispose = geometryDispose;
      THREE.Material.prototype.dispose = materialDispose;
    }
  });

  it("costs the office budget what this workstream reported it would", () => {
    // The city board is at 90 % of its triangle cap and the office at 8 %, so
    // this number is the one integration has to plan against when it mounts the
    // exterior. The caps are deliberately just above the measured figures: a
    // change that doubles the apron should have to come and edit this line.
    for (const [detail, maxDraws, maxTriangles] of [
      ["corridor", 34, 9_000],
      ["follow", 42, 19_000],
    ] as const) {
      const { exterior } = build(MATEO_COURT_SITE, { detail });
      const subtree = meshes(exterior.object);
      const triangles = subtree.reduce((total, mesh) => {
        const geometry = mesh.geometry;
        const index = geometry.getIndex();
        const position = geometry.getAttribute("position");
        return total + (index ? index.count : (position?.count ?? 0)) / 3;
      }, 0);
      assert.ok(
        subtree.length <= maxDraws,
        `${detail}: ${subtree.length} draw calls against ${maxDraws}`,
      );
      assert.ok(
        triangles <= maxTriangles,
        `${detail}: ${triangles} triangles against ${maxTriangles}`,
      );
      exterior.dispose();
    }
  });
});
