/**
 * The apron outside the front door, and the car standing on it.
 *
 * Every shipped pack authors one `ExteriorArrival` — a marked stall on the
 * ground outside the building, in the pack's own plan frame. This layer turns
 * that one anchor into a piece of the world: a paved pad, a painted bay, a kerb,
 * a charge post, and a Model X parked in it whose lamps and cabin reflect a live
 * {@link VehicleTelemetryState}.
 *
 * ### Not every anchor gets one
 *
 * An apron is ground, and one shipped site has none: `lumbridge-hq`'s studio
 * floor is 188 m above the pavement outside it. A pack cannot author a stall
 * anywhere but its own plan frame, so its anchor is a podium kerb described at
 * a storey that is nowhere near the podium — and drawn literally, that is a
 * paved pad and a car hanging in open sky beside a wall, which is what this
 * product shipped for one build. `createOfficeExterior` therefore returns an
 * inert, empty exterior for a site whose arrival storey is not on the ground.
 * The rule is `arrivalGroundFor(site.elevation)` and is discussed where it
 * lives, in `transport/exteriorVehicle.ts`.
 *
 * ### Why the car stands here rather than on the board
 *
 * There has been a Model X in this product since the freeway corridor shipped,
 * and it has only ever existed as traffic: forty instanced glyphs at 0.18 scale
 * on a board where one scene unit is 94 metres, seen from four kilometres up.
 * That is a map symbol. This is the same asset at 1 unit = 1 m, three metres
 * from a doorway you can walk through, which is the first time anything in the
 * product has asked it to be a car — and it is why `assets/vehicles/modelX.ts`
 * was rebuilt with arches, glass openings and a real tyre.
 *
 * ### What this layer owns and what it borrows
 *
 * It owns geometry and exactly one material (the cabin glow, whose emissive
 * strength varies continuously and therefore cannot be a shared registry
 * material). Everything else is borrowed: surface roles come from the
 * `MaterialRegistry`, indicator colours come from `materials.tinted`, and the
 * kerbside planter is built through the `AssetRegistry` so a self-hoster who has
 * re-skinned `tera:planter.trough` gets their trough out here too.
 *
 * `dispose()` frees what this layer made and deliberately leaves the registries
 * alone. Disposing a shared `polishedConcrete` here would blank the floor of the
 * office the apron stands outside of.
 *
 * ### It constructs no light
 *
 * CONTRACT §4: `Atmosphere` is the sole light owner. A charge lamp, a marker
 * lamp and a lit cabin are all *emissive materials*, exactly as
 * `interiors/luminaires.ts` makes a ceiling fitting glow without becoming one.
 * Nothing in this file is a light source, and the release gate's grep for the
 * five three.js light constructors returns nothing over it on purpose.
 */

import * as THREE from "three";
import type { AssetRegistry } from "../assets/kit.ts";
import { createAssetContext } from "../assets/kit.ts";
import type { MaterialRegistry } from "../assets/materials.ts";
import { MeshBin, parts } from "../assets/parts.ts";
import {
  MODEL_X_METRICS,
  MODEL_X_PAINTS,
  buildModelX,
  disposeModelX,
  type ModelXDetail,
} from "../assets/vehicles/index.ts";
import type { ExteriorArrival, OfficeSite } from "../interiors/types.ts";
import {
  apronMetrics,
  arrivalGroundFor,
  exteriorVehicleAppearance,
  lampTint,
  parkPose,
  type ApronMetrics,
  type ExteriorVehicleAppearance,
} from "../transport/exteriorVehicle.ts";
import type { VehicleTelemetryState } from "../transport/vehicleTelemetry.ts";

export interface OfficeExteriorOptions {
  site: OfficeSite;
  arrival: ExteriorArrival;
  assets: AssetRegistry;
  materials: MaterialRegistry;
  /** Seeded per office, so the same studio has the same car outside it forever. */
  rand: () => number;
  /**
   * Which Model X to build.
   *
   * `corridor` is the right answer for a parked car and is what a caller should
   * pass unless the camera is close enough to read a door shutline: 3,192
   * triangles across 18 draw calls, against 13,080 and 25 for `follow`. The
   * difference a viewer can see at three metres is the wing mirrors, the glass
   * frames and the brake calipers; at ten it is nothing at all.
   */
  detail: ModelXDetail;
}

export interface OfficeExterior {
  object: THREE.Object3D;
  /** Reflect one telemetry observation. Cheap, idempotent, safe every frame. */
  apply(telemetry: VehicleTelemetryState): void;
  dispose(): void;
}

/**
 * Where the charge flap sits on the vehicle's left rear quarter, in the asset's
 * own frame.
 *
 * These are not invented: they are the surface the bodyshell loft actually
 * produces there, measured by casting a ray across the flank at that height
 * (`x = −1.0004` on both LODs), plus 3.6 mm so the flap stands proud the way a
 * real one does rather than z-fighting with the paint. Aft of the rear arch and
 * below the shoulder line, which is where a charge port goes on every car that
 * has one.
 */
const CHARGE_PORT = { x: -1.004, y: 0.86, z: 1.95 } as const;

/** Radius of the charge-flap ring, metres. */
const CHARGE_PORT_RADIUS = 0.055;

/**
 * The cabin glow slab, in the vehicle's frame: a dome light under the roof.
 *
 * Deliberately a thin horizontal plate rather than a filled cabin volume. The
 * greenhouse is real glass with an interior behind it, so a lit box would be
 * seen *through* the seats; a plate at head height reads as the light being on
 * and disappears when it is not.
 */
const CABIN_GLOW = { width: 1.24, thickness: 0.02, depth: 2.05, y: 1.3, z: -0.05 } as const;

/** How hard the cabin plate is driven at `cabinGlow === 1`. */
const CABIN_GLOW_MAX_INTENSITY = 1.6;

/**
 * Build the apron and the vehicle at a pack's arrival anchor.
 *
 * The returned object sits at the plan's origin with an identity transform, and
 * everything inside it is positioned in the pack's own metres — so a caller adds
 * it to the level group and nothing has to agree about a frame. That is also
 * what makes the anchor assertable: the Model X's world position is
 * `arrival.position` plus a bounded parking jitter, and nothing else.
 */
export function createOfficeExterior(options: OfficeExteriorOptions): OfficeExterior {
  const { site, arrival, assets, materials, rand, detail } = options;

  const root = new THREE.Group();
  root.name = "office-exterior";
  root.userData.kind = "office-exterior";
  root.userData.arrivalKind = arrival.kind;
  if (arrival.label) root.userData.label = arrival.label;
  if (site.label) root.userData.siteLabel = site.label;

  // ---- Is there anywhere to put it? ---------------------------------------
  //
  // Everything below this line asserts ground: a paved pad, a 135 mm kerb, a
  // charge post bolted to it and a car standing on all three. `site.elevation`
  // is the one number that says whether that ground exists — it measures the
  // arrival storey's floor against **the ground outside**, not against sea level
  // — and above `GROUND_ARRIVAL_MAX_ELEVATION_M` it does not.
  //
  // The alternative shipped for one build and is the reason this check exists:
  // `lumbridge-hq` is 188 m up a Transbay tower, the apron was built at its
  // floor because that is the frame the stall is authored in, and the arrival
  // viewpoint showed a Model X standing on top of a wall with nothing under it.
  // See `ArrivalGround` for the two other repairs that were weighed and why the
  // rule is derived from the site rather than from an office id.
  //
  // An inert exterior rather than `null` on purpose. The caller adds this object
  // to the level group and calls `apply()` on every telemetry publish; making it
  // return an empty root keeps that caller free of a question about architecture
  // it should not have to ask, and an empty `Group` costs no draw call and no
  // triangle. `setVehicleTelemetry` already documents itself as a possible
  // no-op.
  if (arrivalGroundFor(site.elevation) === "air") {
    root.userData.suppressed = "no-ground";
    return {
      object: root,
      apply() {},
      dispose() {
        root.clear();
      },
    };
  }

  const metrics = apronMetrics({
    length: MODEL_X_METRICS.length,
    width: MODEL_X_METRICS.width,
  });

  // Geometries this layer minted and must free. Registry materials are not in
  // here and must not be: they belong to the office this apron stands outside.
  const ownedGeometries: THREE.BufferGeometry[] = [];
  const ownedMaterials: THREE.Material[] = [];

  // ---- The apron ----------------------------------------------------------

  const apron = new THREE.Group();
  apron.name = "office-exterior:apron";
  apron.position.set(arrival.position.x, 0, arrival.position.z);
  // `Yaw` is `object.rotation.y` with no conversion (interiors/types.ts), which
  // is why nothing in this file converts an angle.
  apron.rotation.y = arrival.rotation;
  root.add(apron);

  const paving = materials.tinted("polishedConcrete", 0x6f7370);
  const kerbMaterial = materials.get("skirting");
  const lineMaterial = materials.tinted("polishedConcrete", 0xd9d8cd);
  const postShell = materials.get("deviceShell");
  const postCap = materials.get("metalTrim");

  // Two bins rather than one, split on whether the piece casts a shadow. A
  // `MeshBin` sets the flags per build, and a flat slab lying on the ground has
  // nothing to cast onto while a 1.3 m post very much does.
  const flat = new MeshBin();
  const upright = new MeshBin();
  const top = metrics.padThickness;

  // The slab. `metricQuad` under the top face rather than a scaled `quad`,
  // because the paving carries a texture and its relief now carries a normal
  // map: a unit quad scaled to six metres smears one 2 m tile across the whole
  // pad, colour and relief together.
  flat.box(paving, {
    y: 0,
    size: [metrics.padWidth, metrics.padThickness, metrics.padDepth],
  });
  flat.add(parts.metricQuad(metrics.padWidth, metrics.padDepth), paving, { y: top + 0.001 });

  // A kerb upstand around the pad, with a dropped crossing at the open end.
  //
  // Two decisions in one shape, and both are worth naming. The upstand runs all
  // the way round rather than along one edge because the exterior layer is
  // handed a stall and a site and *neither of them says which side the street
  // is on*: `mateo-court`'s bay runs east along Mateo Street with the façade to
  // its left, `frontier-valley`'s faces an apron with taxiway on three sides,
  // and `lumbridge-hq`'s has no street at all. A carriageway laid on a guessed
  // side would be wrong half the time; a kerbed island is right every time.
  //
  // The gap at +Z is the crossing the car drove in over. Without it the kerb
  // closes the bay on all four sides and the car reads as having been craned
  // into a planter, which is a small thing that reliably breaks the illusion.
  const openingWidth = metrics.stallWidth + 0.4;
  const returnWidth = Math.max(0.2, (metrics.padWidth - openingWidth) / 2);
  const returnX = openingWidth / 2 + returnWidth / 2;
  const frontZ = metrics.padDepth / 2 - metrics.kerbDepth / 2;
  const kerbRuns: [number, number, number, number][] = [
    // The head of the bay, and the two long flanks.
    [0, -metrics.padDepth / 2 + metrics.kerbDepth / 2, metrics.padWidth, metrics.kerbDepth],
    [-metrics.padWidth / 2 + metrics.kerbDepth / 2, 0, metrics.kerbDepth, metrics.padDepth],
    [metrics.padWidth / 2 - metrics.kerbDepth / 2, 0, metrics.kerbDepth, metrics.padDepth],
    // The two returns either side of the crossing.
    [-returnX, frontZ, returnWidth, metrics.kerbDepth],
    [returnX, frontZ, returnWidth, metrics.kerbDepth],
  ];
  for (const [x, z, w, d] of kerbRuns) {
    upright.box(kerbMaterial, { x, y: top, z, size: [w, metrics.kerbHeight, d] });
  }

  // The painted bay: two flanks and a head. Three strips and not a rectangle
  // outline, because a bay is open at the end you drive in through, and the
  // open end is what tells a viewer which way the car came in.
  const lineY = top + 0.004;
  const halfW = metrics.stallWidth / 2;
  const halfL = metrics.stallLength / 2;
  flat.box(lineMaterial, {
    x: -halfW, y: lineY, z: 0,
    size: [metrics.lineWidth, 0.004, metrics.stallLength],
  });
  flat.box(lineMaterial, {
    x: halfW, y: lineY, z: 0,
    size: [metrics.lineWidth, 0.004, metrics.stallLength],
  });
  flat.box(lineMaterial, {
    x: 0, y: lineY, z: -halfL,
    size: [metrics.stallWidth, 0.004, metrics.lineWidth],
  });

  // The charge post. A moulded column with a metal cap and a recessed face; the
  // lamps and the charge bar are separate meshes because they change.
  upright.box(postShell, {
    x: metrics.postOffsetX, y: top, z: metrics.postOffsetZ,
    size: [metrics.postWidth, metrics.postHeight, metrics.postDepth],
  });
  upright.box(postCap, {
    x: metrics.postOffsetX, y: top + metrics.postHeight, z: metrics.postOffsetZ,
    size: [metrics.postWidth + 0.03, 0.035, metrics.postDepth + 0.03],
  });
  upright.box(postCap, {
    x: metrics.postOffsetX, y: top, z: metrics.postOffsetZ,
    size: [metrics.postWidth + 0.06, 0.05, metrics.postDepth + 0.06],
  });

  for (const group of [
    flat.build("office-exterior:paving", { castShadow: false }),
    upright.build("office-exterior:furniture", { castShadow: true }),
  ]) {
    for (const child of group.children) {
      const mesh = child as THREE.Mesh;
      if (mesh.isMesh) ownedGeometries.push(mesh.geometry);
    }
    apron.add(group);
  }

  // ---- Kerbside dressing, through the asset registry ----------------------
  //
  // Built rather than modelled inline so that a self-hoster who registered
  // `acme:planter.trough` with `overrides: "tera:planter.trough"` gets their
  // planter out here as well as inside. Gated on `has()` because a stripped
  // registry is a legitimate configuration and a placeholder box on the kerb is
  // worse than an empty kerb.
  const PLANTER_ID = "tera:planter.trough";
  if (assets.has(PLANTER_ID)) {
    const planter = assets.build(
      PLANTER_ID,
      createAssetContext({ materials, registry: assets, rand }),
    );
    const footprint = assets.footprintOf(PLANTER_ID);
    // Turned side-on so its length runs along the bay rather than across it,
    // and set just inside the kerb on the side away from the charge post.
    planter.rotation.y = Math.PI / 2;
    planter.position.set(halfW + footprint.depth / 2 + 0.18, top, 0);
    planter.name = "office-exterior:planter";
    apron.add(planter);
    planter.traverse((object) => {
      const mesh = object as THREE.Mesh;
      if (mesh.isMesh) ownedGeometries.push(mesh.geometry);
    });
  }

  // ---- Indicators ---------------------------------------------------------
  //
  // One geometry shared by three lamps, and a material per lamp swapped on the
  // way through `materials.tinted`. See `LAMP_INTENSITY_STEPS` for why the
  // brightness is quantised rather than continuous.
  const lampGeometry = new THREE.BoxGeometry(
    metrics.lampSize, metrics.lampSize, metrics.lampSize,
  );
  ownedGeometries.push(lampGeometry);

  function makeLamp(name: string, index: number): THREE.Mesh {
    const mesh = new THREE.Mesh(lampGeometry, materials.tinted("deviceIndicator", 0x000000));
    mesh.name = `office-exterior:${name}`;
    mesh.position.set(
      metrics.postOffsetX,
      top + metrics.lampHeight - index * (metrics.lampSize + 0.04),
      metrics.postOffsetZ + metrics.postDepth / 2,
    );
    apron.add(mesh);
    return mesh;
  }
  const chargeLamp = makeLamp("lamp-charge", 0);
  const climateLamp = makeLamp("lamp-climate", 1);
  const lockLamp = makeLamp("lamp-lock", 2);

  // The charge bar. Scaling a mesh is free; minting a material per percent is
  // not, so the *quantity* is the scale and the colour is fixed.
  const barGeometry = new THREE.BoxGeometry(0.045, 1, 0.012).translate(0, 0.5, 0);
  ownedGeometries.push(barGeometry);
  const chargeBar = new THREE.Mesh(
    barGeometry,
    materials.tinted("deviceIndicator", 0x46d07a),
  );
  chargeBar.name = "office-exterior:charge-bar";
  chargeBar.position.set(
    metrics.postOffsetX,
    top + 0.24,
    metrics.postOffsetZ + metrics.postDepth / 2,
  );
  apron.add(chargeBar);

  // ---- The vehicle --------------------------------------------------------

  const pose = parkPose(arrival, rand);
  const vehicle = new THREE.Group();
  vehicle.name = "office-exterior:vehicle";
  vehicle.position.set(pose.x, top, pose.z);
  vehicle.rotation.y = pose.yaw;
  root.add(vehicle);

  const paintIndex = Math.min(
    MODEL_X_PAINTS.length - 1,
    Math.max(0, Math.floor(rand() * MODEL_X_PAINTS.length)),
  );
  const rig = buildModelX({ detail, paint: MODEL_X_PAINTS[paintIndex] ?? 0x465157 });
  rig.root.name = "office-exterior:model-x";
  vehicle.add(rig.root);

  const portGeometry = new THREE.CircleGeometry(CHARGE_PORT_RADIUS, 20);
  ownedGeometries.push(portGeometry);
  const chargePort = new THREE.Mesh(
    portGeometry,
    materials.tinted("deviceIndicator", 0x000000),
  );
  chargePort.name = "office-exterior:charge-port";
  chargePort.position.set(CHARGE_PORT.x, CHARGE_PORT.y, CHARGE_PORT.z);
  // A `CircleGeometry` faces +Z; the flap is on the vehicle's left, so it turns
  // to face −X.
  chargePort.rotation.y = -Math.PI / 2;
  vehicle.add(chargePort);

  const cabinGeometry = new THREE.BoxGeometry(
    CABIN_GLOW.width, CABIN_GLOW.thickness, CABIN_GLOW.depth,
  );
  ownedGeometries.push(cabinGeometry);
  // The one material this layer owns. It cannot come from the registry because
  // its `emissiveIntensity` is a continuous function of telemetry, and a shared
  // material is shared: dimming this one would dim every indicator in the
  // office with it.
  const cabinMaterial = new THREE.MeshStandardMaterial({
    name: "office-exterior.cabin-glow",
    color: 0xffe9c8,
    emissive: 0xffe4bc,
    emissiveIntensity: 0,
    roughness: 1,
    metalness: 0,
    transparent: true,
    opacity: 0.9,
    // The plate lives inside a closed glass volume and is only ever seen through
    // it. Writing depth would let it punch a hole in the tinted glass in front
    // of it, which reads as a rectangular window cut in the roof.
    depthWrite: false,
  });
  ownedMaterials.push(cabinMaterial);
  const cabinGlow = new THREE.Mesh(cabinGeometry, cabinMaterial);
  cabinGlow.name = "office-exterior:cabin-glow";
  cabinGlow.position.set(0, CABIN_GLOW.y, CABIN_GLOW.z);
  cabinGlow.castShadow = false;
  cabinGlow.receiveShadow = false;
  cabinGlow.visible = false;
  vehicle.add(cabinGlow);

  // ---- The cable ----------------------------------------------------------
  //
  // Built in the root's frame rather than either child's, because its two ends
  // live in different frames: the socket is on the post (apron frame) and the
  // flap is on the car (vehicle frame, which carries the parking jitter). A
  // curve between two world points is the only version of this that stays
  // attached when the car parks 90 mm off the line.
  // Both children have just been positioned and nothing has rendered yet, so
  // their world matrices are stale until this runs.
  root.updateMatrixWorld(true);
  const socket = apron.localToWorld(
    new THREE.Vector3(
      metrics.postOffsetX,
      top + 0.72,
      metrics.postOffsetZ + metrics.postDepth / 2,
    ),
  );
  const flap = vehicle.localToWorld(
    new THREE.Vector3(CHARGE_PORT.x - 0.02, CHARGE_PORT.y, CHARGE_PORT.z),
  );
  const sag = socket.clone().add(flap).multiplyScalar(0.5);
  // A charging cable hangs. Half a metre of droop over a two-metre span is what
  // a heavy DC lead actually does, and it is the difference between a cable and
  // a stick.
  sag.y = Math.min(socket.y, flap.y) - 0.42;
  const cableGeometry = new THREE.TubeGeometry(
    new THREE.QuadraticBezierCurve3(socket, sag, flap),
    12,
    0.021,
    6,
    false,
  );
  ownedGeometries.push(cableGeometry);
  const cable = new THREE.Mesh(cableGeometry, postShell);
  cable.name = "office-exterior:cable";
  cable.castShadow = true;
  cable.receiveShadow = true;
  cable.visible = false;
  root.add(cable);

  // ---- Telemetry ----------------------------------------------------------

  /**
   * The last appearance applied, as a string.
   *
   * `apply` is safe to call every frame and a caller should not have to know
   * that. Everything downstream of a change is cheap except the registry tint
   * lookups, and those are a `Map.get` and a string concat each — small, but
   * three of them sixty times a second for a car that has not changed state in
   * an hour is work nobody asked for.
   */
  let lastSignature = "";

  function signatureOf(look: ExteriorVehicleAppearance, plugged: boolean): string {
    return [
      lampTint(look.charge), lampTint(look.climate), lampTint(look.lock),
      look.cabinGlow.toFixed(3), look.chargeFraction.toFixed(3), plugged ? 1 : 0,
    ].join("|");
  }

  function apply(telemetry: VehicleTelemetryState): void {
    const look = exteriorVehicleAppearance(telemetry);
    const signature = signatureOf(look, telemetry.pluggedIn);
    if (signature === lastSignature) return;
    lastSignature = signature;

    chargeLamp.material = materials.tinted("deviceIndicator", lampTint(look.charge));
    climateLamp.material = materials.tinted("deviceIndicator", lampTint(look.climate));
    lockLamp.material = materials.tinted("deviceIndicator", lampTint(look.lock));
    chargePort.material = materials.tinted("deviceIndicator", lampTint(look.charge));

    // A zero-height bar is a degenerate mesh rather than an absent one, so the
    // floor of the scale is a millimetre and visibility carries the rest.
    chargeBar.scale.y = Math.max(0.001, look.chargeFraction * 0.62);
    chargeBar.visible = look.chargeFraction > 0.005;

    cabinMaterial.emissiveIntensity = look.cabinGlow * CABIN_GLOW_MAX_INTENSITY;
    cabinGlow.visible = look.cabinGlow > 0.01;

    cable.visible = telemetry.pluggedIn;
  }

  return {
    object: root,
    apply,
    dispose() {
      // The rig owns its own materials (nothing external was handed in), so it
      // frees them; `disposeModelX` already defaults to exactly that when
      // `ownsMaterials` is true, and it is stated here rather than implied.
      disposeModelX(rig, { disposeMaterials: true });
      for (const geometry of ownedGeometries) geometry.dispose();
      ownedGeometries.length = 0;
      for (const material of ownedMaterials) material.dispose();
      ownedMaterials.length = 0;
      root.clear();
      apron.clear();
      vehicle.clear();
    },
  };
}

/** Re-exported so a caller can size a bay without reaching into `transport/`. */
export type { ApronMetrics };
