/**
 * 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';

/**
 * Perpendicular distance in XZ from point `p` to the segment a→b. The camera
 * cares about the whole segment, not the endpoints: a post the camera stands
 * clear of can still be planted squarely between it and the player's head.
 */
function distToSegmentXZ(p, a, b) {
  const abx = b.x - a.x, abz = b.z - a.z;
  const len2 = abx * abx + abz * abz;
  let t = len2 ? ((p.x - a.x) * abx + (p.z - a.z) * abz) / len2 : 0;
  t = t < 0 ? 0 : t > 1 ? 1 : t;
  return Math.hypot(p.x - (a.x + abx * t), p.z - (a.z + abz * t));
}

/**
 * The opening yaw: point the camera so the first frame is player + what matters,
 * with nothing skewered through the middle of it. (SPRINT13 gate 2.5.)
 *
 * The QA pass put it plainly: "the boot camera puts a pole dead-centre through
 * the player on every single first impression". Measured rather than eyeballed —
 * the player spawns at (0,6) on backyard_01 looking up the yard at yaw 0, which
 * parks the camera near z≈10.4, and post p3 stands at (0,7). Dead between the
 * camera and the head, every boot, on a public URL.
 *
 * Two things this is NOT:
 *  · not a magic yaw. A hand-picked angle is one site's answer, and sites are
 *    data now — site_02 spawns somewhere else with its posts somewhere else, and
 *    a constant tuned against the backyard would frame the corner block by luck.
 *  · not the camera's existing collision. That pulls IN to the first solid in
 *    the way, so a post behind the player trades a skewer for a shoulder-filling
 *    close-up. Both are the bad frame; this picks a different angle instead.
 *
 * Sweeps outward from the ideal (camera opposite `lookAt`, so the player stands
 * in front of the thing they're here to protect) and takes the FIRST yaw with
 * real clearance — nearest the ideal wins, so the framing gives up as little as
 * it can.
 *
 * TWO NUMBERS HERE ARE MEASURED, not chosen, and both were wrong first:
 *
 * `clearance` 0.6 — my first pass asked for 1.1 m and produced a WORSE frame
 * than the bug it fixed: the camera swung 105° off the garden and stared at a
 * fence. Reason, measured in the running game: p3 stands at (0,7) and the player
 * spawns at (0,6), so the post is **1.0 m away** — and the greatest perpendicular
 * distance any obstacle can ever have from the camera→head line is its own
 * distance from the player. 1.1 was unreachable by construction. The sweep
 * dutifully tried all 24 candidates, failed every one, and fell back to
 * "roomiest", which is a rule that knows nothing about the garden. Clearance is
 * `sin(off) × 1.0 m` here, so 0.6 buys ~37° of turn and leaves the bed 23° off
 * centre in a 62° FOV: in frame, next to the player, no pole through the head.
 *
 * `maxOff` 90° — the fallback's bound, and the lesson from the same failure. A
 * frame that has turned more than a quarter-circle off the bed is no longer a
 * frame of the bed, so it cannot be the "best available" answer no matter how
 * roomy it is. Past this, a pole in shot is the lesser evil, and the sweep says
 * so by returning the roomiest angle WITHIN the arc rather than outside it.
 *
 * Never throws, never returns NaN: a yard boxed in on every side still gets its
 * best available frame.
 *
 * @param {{x,z}} player
 * @param {{x,z}} lookAt      what the frame should be about — the garden bed
 * @param {{x,z}[]} obstacles vertical things, by XZ: posts, trunks, structures
 * @param {object} [opts]
 * @returns {number} yaw in radians
 */
export function spawnYawFor(player, lookAt, obstacles = [], opts = {}) {
  const distance = opts.distance ?? DEFAULTS.distance;
  const clearance = opts.clearance ?? 0.6;
  const maxOff = opts.maxOff ?? Math.PI / 2;
  const steps = opts.steps ?? 24;
  const ideal = Math.atan2(player.x - lookAt.x, player.z - lookAt.z);

  const clearAt = (yaw) => {
    const cam = { x: player.x + Math.sin(yaw) * distance, z: player.z + Math.cos(yaw) * distance };
    let clear = Infinity;
    for (const o of obstacles) clear = Math.min(clear, distToSegmentXZ(o, player, cam));
    return clear;
  };

  let best = ideal, bestClear = clearAt(ideal);
  if (bestClear >= clearance) return ideal;

  // Alternate outward — +7.5°, −7.5°, +15° … — so "first acceptable" also means
  // "least turned away from the bed". Bounded by maxOff: see above.
  for (let i = 1; i <= steps; i++) {
    const mag = (Math.ceil(i / 2) * maxOff) / Math.ceil(steps / 2);
    if (mag > maxOff) break;
    const yaw = ideal + (i % 2 ? mag : -mag);
    const clear = clearAt(yaw);
    if (clear >= clearance) return yaw;
    if (clear > bestClear) { bestClear = clear; best = yaw; }
  }
  return best;
}

/** 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 ?? []; },

    /**
     * What the camera is currently colliding against. A read-only view (spread,
     * so a caller can't mutate the live array). SPRINT13: added so the
     * aftermath cloth-swallow fix is observable — "did the dead sail actually
     * join the solid set" was otherwise a claim with no way to check it.
     */
    get solids() { return [...solids]; },

    /** @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);
    },
  };
}
