/**
 * SHADES — third-person camera rig. Lane A owns this file.
 *
 * Shoulder-follow by default, orbit while the right mouse button is held.
 * Two things here are load-bearing for other lanes:
 *   - `yaw` is what Lane D's WASD is relative to.
 *   - the camera collides against world.solids, because the house sits on the
 *     north edge of a 20 m yard and a naive follow cam walks straight through
 *     the wall the moment the player stands with their back to it.
 */

import * as THREE from '../vendor/three.module.js';

/** Stand-off kept between the camera and whatever it collided with. */
const WALL_MARGIN = 0.25;
/** Absolute floor on camera-to-head distance. Above the 0.1 near plane. */
const NEAR_FLOOR = 0.15;
/** How far the camera stays above the terrain. */
const GROUND_CLEARANCE = 0.35;

const DEFAULTS = {
  distance: 4.5,        // meters behind the target
  height: 1.55,         // look-at height on the player (roughly the head)
  shoulder: 0.55,       // lateral offset — the "third person" of it
  pitch: -0.18,         // radians, slightly down
  minPitch: -1.15,
  maxPitch: 0.55,
  yaw: 0.0,
  sensitivity: 0.0042,  // radians per pixel
  followLambda: 9,      // position smoothing rate (higher = stiffer)
  fov: 62,
};

/**
 * @param {HTMLElement} domElement  Element that receives the orbit drag.
 * @param {object} [opts]
 * @returns {import('./contracts.js').CameraRig}
 */
export function createCameraRig(domElement, opts = {}) {
  const cfg = { ...DEFAULTS, ...opts };

  const object = new THREE.PerspectiveCamera(cfg.fov, 1, 0.1, 400);
  object.position.set(0, 3, 8);

  let yaw = cfg.yaw;
  let pitch = cfg.pitch;
  let dragging = false;
  let lastX = 0, lastY = 0;

  // Smoothed camera position. Seeded on the first update so the camera doesn't
  // fly in from the origin on frame one.
  const smoothed = new THREE.Vector3();
  let seeded = false;

  const onContextMenu = (e) => e.preventDefault();
  const onPointerDown = (e) => {
    if (e.button !== 2) return;      // RMB only
    dragging = true;
    lastX = e.clientX; lastY = e.clientY;
    domElement.setPointerCapture?.(e.pointerId);
  };
  const onPointerMove = (e) => {
    if (!dragging) return;
    yaw -= (e.clientX - lastX) * cfg.sensitivity;
    pitch -= (e.clientY - lastY) * cfg.sensitivity;
    pitch = Math.max(cfg.minPitch, Math.min(cfg.maxPitch, pitch));
    lastX = e.clientX; lastY = e.clientY;
  };
  const onPointerUp = (e) => {
    if (e.button !== 2) return;
    dragging = false;
    domElement.releasePointerCapture?.(e.pointerId);
  };
  const onWheel = (e) => {
    cfg.distance = Math.max(2, Math.min(9, cfg.distance + Math.sign(e.deltaY) * 0.4));
  };

  domElement.addEventListener('contextmenu', onContextMenu);
  domElement.addEventListener('pointerdown', onPointerDown);
  domElement.addEventListener('pointermove', onPointerMove);
  domElement.addEventListener('pointerup', onPointerUp);
  domElement.addEventListener('wheel', onWheel, { passive: true });

  const target = new THREE.Vector3();
  const desired = new THREE.Vector3();
  const dir = new THREE.Vector3();
  const ray = new THREE.Raycaster();
  ray.far = 20;

  /** @type {THREE.Object3D[]} */
  let solids = [];
  /** @type {(x:number, z:number) => number} */
  let groundAt = () => -Infinity;

  return {
    object,

    get yaw() { return yaw; },
    set yaw(v) { yaw = v; },

    get pitch() { return pitch; },

    /**
     * Obstacle meshes the camera must not pass through. NOT the ground — see
     * setGround(). Raycasting a 4800-triangle terrain every frame to learn
     * something world.heightAt() answers in closed form is the kind of waste
     * that only shows up once four other systems are also running.
     */
    setSolids(list) { solids = list ?? []; },

    /** @param {(x:number, z:number) => number} fn  Pass world.heightAt. */
    setGround(fn) { groundAt = fn ?? (() => -Infinity); },

    /**
     * @param {number} dt
     * @param {THREE.Vector3} targetPos  Player feet position.
     */
    update(dt, targetPos) {
      target.set(targetPos.x, targetPos.y + cfg.height, targetPos.z);

      // Orbit offset, then slide sideways for the over-the-shoulder framing.
      const cp = Math.cos(pitch);
      dir.set(Math.sin(yaw) * cp, -Math.sin(pitch), Math.cos(yaw) * cp).normalize();
      desired.copy(target).addScaledVector(dir, cfg.distance);
      desired.x += Math.cos(yaw) * cfg.shoulder;
      desired.z -= Math.sin(yaw) * cfg.shoulder;

      // Collision: cast from the head toward where the camera wants to be and
      // pull in to the first thing in the way.
      if (solids.length) {
        const toCam = desired.clone().sub(target);
        const dist = toCam.length();
        ray.set(target, toCam.divideScalar(dist || 1));
        ray.far = dist;
        const hit = ray.intersectObjects(solids, true)[0];
        if (hit) {
          // The wall wins. A comfortable stand-off distance is a preference; a
          // camera inside the house is a bug, so when the two disagree the
          // geometry gets the last word and the player eats a shoulder-filling
          // close-up instead. (Getting this backwards — clamping UP to a
          // minimum distance after the raycast — is what put the camera 17 cm
          // inside the north wall the first time round.)
          const safe = Math.min(dist, hit.distance - WALL_MARGIN);
          desired.copy(target).addScaledVector(ray.ray.direction, Math.max(NEAR_FLOOR, safe));
        }
      }

      // Ground is handled analytically rather than by raycast: exact, can't be
      // tunnelled through, and free.
      desired.y = Math.max(desired.y, groundAt(desired.x, desired.z) + GROUND_CLEARANCE);

      if (!seeded) { smoothed.copy(desired); seeded = true; }
      // Exponential smoothing that is correct at any dt (not the usual
      // frame-rate-dependent lerp).
      const a = 1 - Math.exp(-cfg.followLambda * dt);
      smoothed.lerp(desired, a);

      object.position.copy(smoothed);
      object.lookAt(target);
    },

    /** Keep the projection square with the canvas. */
    resize(width, height) {
      object.aspect = width / Math.max(1, height);
      object.updateProjectionMatrix();
    },

    dispose() {
      domElement.removeEventListener('contextmenu', onContextMenu);
      domElement.removeEventListener('pointerdown', onPointerDown);
      domElement.removeEventListener('pointermove', onPointerMove);
      domElement.removeEventListener('pointerup', onPointerUp);
      domElement.removeEventListener('wheel', onWheel);
    },
  };
}
