/**
 * People at seats.
 *
 * This is `markers.ts` one level in, and it is deliberately just as ignorant. It
 * renders `Presence[]`, looks colours up by `colorKey` in a palette the caller
 * supplies, and does not know that a presence is a person, that a colour means
 * "in today", or that anybody works anywhere. That mapping belongs to the
 * adapter in the consuming app. See ARCHITECTURE.md §3.3.
 *
 * ### Bound to a seat id, never to a coordinate
 *
 * A `Presence` carries `seatId` and no position, and this file is where that
 * pays off: the office pack knows where `eng-04` is, a private API knows who is
 * in it, and neither one has to know the other. Publishing the geometry and
 * publishing the people are therefore two separate acts, which is what lets the
 * first happen at all. A presence whose seat is not in the plan is dropped —
 * there is nowhere to put it, and inventing a spot on the floor would quietly
 * turn a private id into a public coordinate, which is the exact thing the split
 * exists to prevent.
 *
 * ### The public office does not call this file
 *
 * `createOfficeScene(office, { depth: "public" })` never constructs a
 * `PresenceLayer`. Not an empty one, not a hidden one — none. That is worth
 * stating here rather than only at the call site, because the tempting change to
 * this file, the first time somebody wants an anonymous view, is a `visible`
 * flag or an `if (anonymous) return` inside `setPresence`. Both of those leave a
 * layer in the scene graph that is one call away from being populated, and
 * `officeScene.ts` is where the decision belongs precisely so that the layer
 * that must not exist is not built at all.
 *
 * The split above is what makes that cheap: an office pack has no people in it,
 * so a building with no presence layer is not a building with something taken
 * out of it. It is the same building, before anyone arrived.
 *
 * ### Figures
 *
 * Two poses, one merged geometry each, one material per colour, one mesh per
 * person. Low-poly on purpose: at fifty people that is fifty draw calls, which
 * is the same order as the whole rest of the office, and a room reads as
 * occupied from the silhouette long before anyone counts the polygons.
 */

import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import {
  parts as sharedParts,
  placementMatrix,
  type PartBin,
  type Placement,
} from "../assets/parts.ts";
import type { Plan } from "./plan.ts";
import type { Presence, SeatPose } from "./types.ts";

/**
 * Caller-supplied `colorKey` -> colour. The same shape as the city's
 * `MarkerPalette` and named separately on purpose: they are two palettes with
 * two sets of keys, and one type exported twice meaning two things is the
 * failure CONTRACT.md exists to stop.
 */
export type PresencePalette = Record<string, number>;

const FALLBACK_COLOR = 0x9aa4ad;

/** How far above the crown an HTML label should hang, in metres. */
const LABEL_LIFT = 0.16;

export interface PresenceLayer {
  group: THREE.Group;
  /** Raycast targets, for hover and click. One per person. */
  pickables: THREE.Object3D[];
  /** Scene-space head position per presence id, for the HTML label layer. */
  anchors: Map<string, THREE.Vector3>;
  setPresence(people: Presence[]): void;
  dispose(): void;
}

export interface PresenceOptions {
  parts?: PartBin;
}

export function createPresenceLayer(
  plan: Plan,
  palette: PresencePalette,
  options: PresenceOptions = {},
): PresenceLayer {
  const parts = options.parts ?? sharedParts;
  const group = new THREE.Group();
  group.name = "presence";
  const pickables: THREE.Object3D[] = [];
  const anchors = new Map<string, THREE.Vector3>();

  // Built on first use rather than up front: an office with nobody in it should
  // not pay for two figures it never draws.
  const figures = new Map<SeatPose, { geometry: THREE.BufferGeometry; crown: number }>();
  const materials = new Map<string, THREE.Material>();
  const warned = new Set<string>();

  function figureFor(pose: SeatPose) {
    const hit = figures.get(pose);
    if (hit) return hit;
    const made = buildFigure(parts, pose);
    figures.set(pose, made);
    return made;
  }

  function materialFor(key: string): THREE.Material {
    const hit = materials.get(key);
    if (hit) return hit;
    // Standard rather than Lambert, unlike the city's pins: an office is lit by
    // a physically-shaded rig and a Lambert figure in it reads as a cardboard
    // cutout. No map — a `colorKey` is a colour and nothing else.
    const made = new THREE.MeshStandardMaterial({
      color: palette[key] ?? FALLBACK_COLOR,
      roughness: 0.72,
      metalness: 0.02,
    });
    made.name = `presence:${key}`;
    materials.set(key, made);
    return made;
  }

  function clear() {
    for (const child of [...group.children]) group.remove(child);
    pickables.length = 0;
    anchors.clear();
  }

  function setPresence(people: Presence[]) {
    clear();
    for (const person of people) {
      const seat = plan.seat(person.seatId);
      if (!seat) {
        // Once per seat id, not once per update: a feed pointed at last
        // quarter's floorplan should say so, not fill the console.
        if (!warned.has(person.seatId)) {
          warned.add(person.seatId);
          console.warn(`[tera/interiors] presence "${person.id}" sits at unknown seat "${person.seatId}"`);
        }
        continue;
      }

      const figure = figureFor(seat.pose);
      const mesh = new THREE.Mesh(figure.geometry, materialFor(person.colorKey));
      mesh.name = `presence:${person.id}`;
      mesh.position.set(seat.position.x, seat.y, seat.position.z);
      // The seat's own facing, unconverted. A seat, the chair at it and the
      // person in it all carry one rotation — see `Yaw` in `types.ts`.
      mesh.rotation.y = seat.facing;
      mesh.castShadow = true;
      mesh.receiveShadow = true;
      mesh.userData.presence = person;
      group.add(mesh);
      pickables.push(mesh);
      anchors.set(
        person.id,
        new THREE.Vector3(seat.position.x, seat.y + figure.crown + LABEL_LIFT, seat.position.z),
      );
    }
  }

  return {
    group,
    pickables,
    anchors,
    setPresence,
    dispose() {
      clear();
      for (const figure of figures.values()) figure.geometry.dispose();
      figures.clear();
      for (const material of materials.values()) material.dispose();
      materials.clear();
    },
  };
}

// ---- Figures --------------------------------------------------------------

/**
 * One person, as eleven primitives merged into one buffer.
 *
 * Built out of the shared `PartBin` rather than hand-rolled cylinders for the
 * same reason every asset is: a limb that is the same `rod()` as a chair leg
 * looks like it belongs in the same world. The proportions are a 1.72 m adult;
 * the seated pose puts the hips at a 460 mm seat pan, which is the height
 * `tera:seat.task-chair` is authored at.
 *
 * A figure faces **-Z** at yaw zero, which is what a seat's `facing` means and
 * which is why an occupant, their chair and their desk all take one rotation.
 * Knees therefore run toward -Z and the backrest is behind the figure at +Z.
 *
 * Returns the crown height as well, because the label anchor wants it and
 * measuring a merged buffer afterwards to find out how tall a person is would be
 * silly.
 */
function buildFigure(
  parts: PartBin,
  pose: SeatPose,
): { geometry: THREE.BufferGeometry; crown: number } {
  const pieces: { geometry: THREE.BufferGeometry; place: Placement }[] = [];
  const add = (geometry: THREE.BufferGeometry, place: Placement) => {
    pieces.push({ geometry, place });
  };

  const limb = parts.cylinder(8);
  const torso = parts.cylinder(10);
  const head = parts.sphere(10);
  const hipSpan = 0.11;
  const armSpan = 0.21;

  let crown: number;
  if (pose === "stand") {
    const hip = 0.86;
    const shoulder = 1.4;
    for (const side of [-1, 1]) {
      add(limb, { x: side * hipSpan, y: 0, size: [0.15, hip, 0.16] });
      add(limb, { x: side * armSpan, y: shoulder - 0.46, size: [0.11, 0.46, 0.12] });
    }
    add(torso, { y: hip, size: [0.42, shoulder - hip, 0.26] });
    add(limb, { y: shoulder, size: [0.11, 0.07, 0.11] });
    add(head, { y: shoulder + 0.05, size: [0.22, 0.24, 0.23] });
    crown = shoulder + 0.29;
  } else {
    // Seated: shins down in front of the seat, thighs forward at pan height,
    // torso up from the hips. A thigh is a limb pitched by -pi/2, which sends
    // the unit cylinder's +Y along -Z — out in front of the occupant.
    const pan = 0.46;
    const knee = 0.3;
    const shoulder = pan + 0.5;
    for (const side of [-1, 1]) {
      add(limb, { x: side * hipSpan, y: 0, z: -knee, size: [0.14, pan - 0.02, 0.15] });
      add(limb, {
        x: side * hipSpan,
        y: pan - 0.02,
        z: 0.02,
        size: [0.15, knee + 0.06, 0.16],
        pitch: -Math.PI / 2,
      });
      add(limb, { x: side * armSpan, y: shoulder - 0.42, size: [0.1, 0.42, 0.11] });
    }
    add(torso, { y: pan, size: [0.4, shoulder - pan, 0.26] });
    add(limb, { y: shoulder, size: [0.1, 0.06, 0.1] });
    add(head, { y: shoulder + 0.04, size: [0.21, 0.23, 0.22] });
    crown = shoulder + 0.27;
  }

  const matrix = new THREE.Matrix4();
  const transformed = pieces.map((piece) => {
    const clone = piece.geometry.clone();
    clone.applyMatrix4(placementMatrix(piece.place, matrix));
    return clone;
  });
  // Every part here is an indexed primitive from the same bin, so the merge
  // cannot hit the indexed/non-indexed refusal documented in `assets/office/
  // common.ts`. If a future figure grows a `roundedBox` shoulder, it will.
  const merged = mergeGeometries(transformed, false);
  for (const geometry of transformed) geometry.dispose();
  if (!merged) throw new Error("presence: could not merge the figure geometry");
  merged.name = `presence:${pose}`;
  return { geometry: merged, crown };
}
