/**
 * The shared bin of unit primitives every asset is built out of.
 *
 * Two jobs, and they are the same job seen from two ends.
 *
 * **Draw calls.** The reference office is about 1,200 objects. Built naively —
 * one `THREE.Mesh` per box, one `BoxGeometry` per mesh — that is 1,200 draw
 * calls and about as many geometries, and it drops a laptop to single-figure
 * frame rates before a single desk has anything on it. Built out of this bin it
 * is roughly thirty: every asset composes *cached* unit geometries placed by a
 * scaled local matrix, and `MeshBin` merges everything sharing a material into
 * one buffer. Thirty draw calls is one per `SurfaceRole` actually used.
 *
 * **Coherence.** Eighteen people writing eighteen asset builders produce
 * eighteen dialects unless they are all reaching for the same shapes. A desk leg
 * and a chair column that are both `rod()` scaled differently look related in a
 * way that two hand-tuned cylinders never quite do. The bin is the library's
 * accent.
 *
 * ### Conventions
 *
 * Assets are authored in **metres, 1 unit = 1 m** (CONTRACT.md §3), and every
 * unit part is 1 m in each dimension with its **base on y = 0**, centred in X
 * and Z. So `scale(0.8, 0.73, 0.6)` on a `box()` is a 800 × 600 desktop 730 mm
 * off the floor, and nothing has to remember whether a primitive is
 * origin-centred or base-centred. The exceptions are stated on each method.
 *
 * Unit parts carry 0..1 UVs and therefore stretch under scale. That is fine for
 * the roles that carry no texture map, and it is why the two parts that *do*
 * meet textured surfaces — `metricQuad` and `wallRun` — generate their UVs in
 * metres instead.
 */

import * as THREE from "three";
import { mergeGeometries } from "three/examples/jsm/utils/BufferGeometryUtils.js";
import { TEXTURE_TILE_METRES } from "./textures.ts";

/** Quantise a dimension to millimetres, so near-identical runs share a cache slot. */
function mm(v: number): number {
  return Math.round(v * 1000);
}

export class PartBin {
  private readonly cache = new Map<string, THREE.BufferGeometry>();

  private memo(key: string, make: () => THREE.BufferGeometry): THREE.BufferGeometry {
    const hit = this.cache.get(key);
    if (hit) return hit;
    const made = make();
    made.name = key;
    this.cache.set(key, made);
    return made;
  }

  // ---- Solids -------------------------------------------------------------

  /** 1 m cube, base on the floor. The workhorse. */
  box(): THREE.BufferGeometry {
    return this.memo("box", () => new THREE.BoxGeometry(1, 1, 1).translate(0, 0.5, 0));
  }

  /**
   * A box with rounded vertical corners and bevelled top and bottom — cushions,
   * chair shells, monitor bodies, anything moulded.
   *
   * `radius` is a fraction of the unit and is only correct while the part stays
   * cubic, because a corner is round in *object* space and a non-uniform scale
   * turns a circle into an ellipse. That is not a caveat you can design around
   * — almost nothing in an office is a cube — and it is why `seating.ts` gave up
   * and went back to sharp boxes.
   *
   * **Use `roundedBoxOf` instead**, which takes the finished metres and gets a
   * genuinely circular corner at any proportion. This one stays for the parts
   * that really are cubic, and because it is the cache entry `roundedBoxOf(1, 1,
   * 1, r)` resolves to anyway.
   */
  roundedBox(radius = 0.06): THREE.BufferGeometry {
    return this.roundedBoxOf(1, 1, 1, radius);
  }

  /**
   * The same moulded box, authored at its finished size in metres.
   *
   * The corner radius is in **metres** and is applied after the proportions are
   * known, so a 1.6 × 0.05 × 0.9 desk return gets a 12 mm round on all four
   * corners rather than a 12 mm round on two of them and a 380 mm oval on the
   * others. Place it with `size: 1` — the geometry is already the right size,
   * and scaling it is what this method exists to avoid.
   *
   * The radius is clamped to a fifth of the shortest side. Past that the bevel
   * eats the extrusion (a 0.5 radius on a 0.9-thick shelf has no flat left to
   * extrude) and `ExtrudeGeometry` starts emitting self-intersecting caps.
   */
  roundedBoxOf(width: number, height: number, depth: number, radius = 0.06): THREE.BufferGeometry {
    const shortest = Math.max(0.002, Math.min(width, height, depth));
    const bevel = Math.min(shortest * 0.2, Math.max(0.001, radius));
    return this.memo(
      `rounded:${mm(width)}:${mm(height)}:${mm(depth)}:${mm(bevel)}`,
      () => {
        // The shape is drawn in the extruder's XY and the extrusion runs along
        // its +Z; the `rotateX` below maps that to width × depth on the floor
        // with the extrusion standing up as height.
        const halfX = width / 2 - bevel;
        const halfY = depth / 2 - bevel;
        const r = Math.min(halfX * 0.98, halfY * 0.98, bevel * 2);
        const shape = new THREE.Shape();
        shape.moveTo(-halfX + r, -halfY);
        shape.lineTo(halfX - r, -halfY);
        shape.quadraticCurveTo(halfX, -halfY, halfX, -halfY + r);
        shape.lineTo(halfX, halfY - r);
        shape.quadraticCurveTo(halfX, halfY, halfX - r, halfY);
        shape.lineTo(-halfX + r, halfY);
        shape.quadraticCurveTo(-halfX, halfY, -halfX, halfY - r);
        shape.lineTo(-halfX, -halfY + r);
        shape.quadraticCurveTo(-halfX, -halfY, -halfX + r, -halfY);

        // The bevel overhangs both ends of the extrusion, so the solid spans
        // -bevel..height-bevel before it is stood up and dropped onto the floor.
        const geo = new THREE.ExtrudeGeometry(shape, {
          depth: height - 2 * bevel,
          bevelEnabled: true,
          bevelSize: bevel,
          bevelThickness: bevel,
          bevelSegments: 3,
          curveSegments: 6,
        });
        geo.rotateX(-Math.PI / 2);
        geo.translate(0, bevel, 0);
        geo.computeVertexNormals();
        return geo;
      },
    );
  }

  /** Unit-diameter cylinder, base on the floor. */
  cylinder(segments = 20): THREE.BufferGeometry {
    return this.memo(`cyl:${segments}`, () =>
      new THREE.CylinderGeometry(0.5, 0.5, 1, segments).translate(0, 0.5, 0),
    );
  }

  /**
   * An eight-sided cylinder. Legs, columns, pen barrels — anything thin enough
   * that nobody will count the sides, which is most of the office.
   *
   * Six read as a hexagon on a chair column at desk distance, and the office
   * measures 44,754 triangles against a 550,000 budget: eight is four extra
   * triangles on the commonest part in the library and there is nowhere for the
   * saving to go.
   */
  rod(): THREE.BufferGeometry {
    return this.cylinder(8);
  }

  /** Unit-diameter cone, base on the floor. */
  cone(segments = 12): THREE.BufferGeometry {
    return this.memo(`cone:${segments}`, () =>
      new THREE.ConeGeometry(0.5, 1, segments).translate(0, 0.5, 0),
    );
  }

  /** Unit-diameter sphere, resting on the floor rather than centred on it. */
  sphere(segments = 16): THREE.BufferGeometry {
    return this.memo(`sph:${segments}`, () =>
      new THREE.SphereGeometry(0.5, segments, Math.max(4, segments >> 1)).translate(0, 0.5, 0),
    );
  }

  // ---- Surfaces -----------------------------------------------------------

  /** 1 × 1 plane lying in XZ, facing up. Centred, not base-offset. */
  quad(): THREE.BufferGeometry {
    return this.memo("quad", () => new THREE.PlaneGeometry(1, 1).rotateX(-Math.PI / 2));
  }

  /** 1 × 1 plane standing in XY, facing +Z, base on the floor. */
  panel(): THREE.BufferGeometry {
    return this.memo("panel", () => new THREE.PlaneGeometry(1, 1).translate(0, 0.5, 0));
  }

  /** Unit-diameter disc lying in XZ, facing up. */
  disc(segments = 32): THREE.BufferGeometry {
    return this.memo(`disc:${segments}`, () =>
      new THREE.CircleGeometry(0.5, segments).rotateX(-Math.PI / 2),
    );
  }

  /**
   * A floor-plane rectangle of a given size in metres, with **UVs in metres**
   * so a carpet reads the same size in a 3 m booth and a 30 m floor plate.
   *
   * This is the part to use under any textured surface. `quad()` scaled to the
   * same size would smear one tile of carpet across the whole room.
   */
  metricQuad(width: number, depth: number): THREE.BufferGeometry {
    return this.memo(`mq:${mm(width)}:${mm(depth)}`, () => {
      const geo = new THREE.PlaneGeometry(width, depth).rotateX(-Math.PI / 2);
      const uv = geo.getAttribute("uv");
      for (let i = 0; i < uv.count; i++) {
        uv.setXY(
          i,
          (uv.getX(i) * width) / TEXTURE_TILE_METRES,
          (uv.getY(i) * depth) / TEXTURE_TILE_METRES,
        );
      }
      uv.needsUpdate = true;
      return geo;
    });
  }

  /**
   * One solid run of wall: a box `length` long, `height` tall and `thickness`
   * deep, running along local +X with its base on the floor and centred on its
   * line.
   *
   * This is the part `Plan` hands each piece of wall left over after its
   * openings are punched out (CONTRACT.md §2). There is deliberately no door or
   * window asset to go with it — shipping both a door prop and a door-shaped
   * hole puts every opening in the scene twice, or leaves the collider with no
   * gap where the door is.
   *
   * Each face gets UVs in metres over its own two extents, so the finish does
   * not stretch on a long wall or squash on the reveal at its end.
   */
  wallRun(length: number, height: number, thickness: number): THREE.BufferGeometry {
    return this.memo(`wall:${mm(length)}:${mm(height)}:${mm(thickness)}`, () => {
      const geo = new THREE.BoxGeometry(length, height, thickness);
      // BoxGeometry emits its faces in a fixed order — +X, -X, +Y, -Y, +Z, -Z —
      // four vertices each at the default one segment per side.
      const extents: [number, number][] = [
        [thickness, height],
        [thickness, height],
        [length, thickness],
        [length, thickness],
        [length, height],
        [length, height],
      ];
      const uv = geo.getAttribute("uv");
      for (let face = 0; face < 6; face++) {
        const extent = extents[face] ?? [1, 1];
        for (let v = 0; v < 4; v++) {
          const i = face * 4 + v;
          uv.setXY(
            i,
            (uv.getX(i) * extent[0]) / TEXTURE_TILE_METRES,
            (uv.getY(i) * extent[1]) / TEXTURE_TILE_METRES,
          );
        }
      }
      uv.needsUpdate = true;
      return geo.translate(0, height / 2, 0);
    });
  }

  dispose(): void {
    for (const geo of this.cache.values()) geo.dispose();
    this.cache.clear();
  }
}

/**
 * The bin every asset uses. Module-level and shared on purpose — a second bin
 * is a second copy of every geometry, and the whole argument for the bin is
 * that there is one of each.
 */
export const parts = new PartBin();

// ---- Placement ------------------------------------------------------------

export interface Placement {
  /** Metres. `y` is the base of the part, matching the unit convention. */
  x?: number;
  y?: number;
  z?: number;
  /** Metres in each axis. A number scales all three. */
  size?: number | [number, number, number];
  /** Yaw about +Y, radians, three.js sense. */
  yaw?: number;
  /** Pitch about +X and roll about +Z, for the rare tilted part. */
  pitch?: number;
  roll?: number;
}

const scratchPosition = new THREE.Vector3();
const scratchQuaternion = new THREE.Quaternion();
const scratchEuler = new THREE.Euler();
const scratchScale = new THREE.Vector3();

export function placementMatrix(p: Placement, into = new THREE.Matrix4()): THREE.Matrix4 {
  const size = p.size ?? 1;
  scratchPosition.set(p.x ?? 0, p.y ?? 0, p.z ?? 0);
  scratchEuler.set(p.pitch ?? 0, p.yaw ?? 0, p.roll ?? 0, "YXZ");
  scratchQuaternion.setFromEuler(scratchEuler);
  if (typeof size === "number") scratchScale.set(size, size, size);
  else scratchScale.set(size[0], size[1], size[2]);
  return into.compose(scratchPosition, scratchQuaternion, scratchScale);
}

/**
 * Collects transformed parts and emits **one mesh per material**.
 *
 * This is where the draw-call budget is actually spent. An asset builder adds
 * forty boxes across five materials and gets a `THREE.Group` of five meshes
 * back; a `Plan` that pours a whole floor's props into one bin gets five meshes
 * for the floor. Merging costs a geometry clone per part during the build and
 * nothing afterwards, which is the right trade for something built once and
 * looked at for an hour.
 *
 * The cost of merging is that the parts stop being individually addressable —
 * you cannot move one chair after the fact. Anything that has to move on its own
 * (a door leaf, a hovering label, a selected prop) belongs in its own object
 * rather than in a bin.
 */
export class MeshBin {
  private readonly groups = new Map<THREE.Material, THREE.BufferGeometry[]>();
  private readonly matrix = new THREE.Matrix4();

  /** Add a part under a transform. The geometry is cloned, never mutated. */
  add(geometry: THREE.BufferGeometry, material: THREE.Material, place: Placement = {}): this {
    const clone = geometry.clone();
    clone.applyMatrix4(placementMatrix(place, this.matrix));
    const list = this.groups.get(material);
    if (list) list.push(clone);
    else this.groups.set(material, [clone]);
    return this;
  }

  /** `add(parts.box(), …)`, which is most of what any asset does. */
  box(material: THREE.Material, place: Placement): this {
    return this.add(parts.box(), material, place);
  }

  /**
   * A moulded box with a true corner radius at whatever proportions `place.size`
   * asks for — the drop-in replacement for `box()` on anything that is not a
   * sawn edge.
   *
   * The size is spent on the *geometry* rather than on the placement matrix,
   * which is the whole trick: `parts.roundedBox()` scaled to 1.4 × 0.06 × 0.7
   * has 42 mm corners on two sides and 3 mm on the others, and looking at that
   * is why `seating.ts` reverted to sharp boxes. Everything else about the
   * placement — position, yaw, pitch, roll — is passed through untouched.
   *
   * The cost is a cache entry per distinct size rather than one for the whole
   * library, so this is for the parts a reader will see the silhouette of, not
   * for a hundred randomised trinkets.
   */
  rounded(material: THREE.Material, place: Placement, radius = 0.02): this {
    const size = place.size ?? 1;
    const [w, h, d] = typeof size === "number" ? [size, size, size] : size;
    return this.add(parts.roundedBoxOf(w, h, d, radius), material, { ...place, size: 1 });
  }

  /** Number of parts waiting to be merged. Handy in an asset's own tests. */
  get size(): number {
    let n = 0;
    for (const list of this.groups.values()) n += list.length;
    return n;
  }

  build(
    name = "parts",
    options: { castShadow?: boolean; receiveShadow?: boolean } = {},
  ): THREE.Group {
    const group = new THREE.Group();
    group.name = name;
    for (const [material, list] of this.groups) {
      const merged = list.length === 1 ? list[0] : mergeGeometries(list, false);
      if (!merged) continue;
      if (list.length > 1) for (const geo of list) geo.dispose();
      const mesh = new THREE.Mesh(merged, material);
      mesh.name = `${name}:${material.name || "material"}`;
      mesh.castShadow = options.castShadow ?? true;
      mesh.receiveShadow = options.receiveShadow ?? true;
      group.add(mesh);
    }
    this.groups.clear();
    return group;
  }
}
