/**
 * player.js — the small person: rig, clips, camera-relative control.  (Lane D)
 *
 * The deterministic half lives in player.sim.js; this file is the view. It follows the 90sDJsim
 * DEVMANUAL "Rigged animated characters" rules, which are law here:
 *   · SkeletonUtils.clone() for instances — a plain .clone() breaks skinned meshes
 *   · height-normalise off the HEAD BONE and plant the feet — Mixamo scale is unreliable, so the
 *     scale factor is always MEASURED, never a blind setScalar of a guessed constant
 *   · canonicalise the bone namespace so any clip binds to any character
 *
 * Assets (see models/MODELS.md): player_01.glb is an untouched ped (metre-scale, head bone 1.75 m);
 * player_anims.glb is an anim-only carrier built by tools/character/build_player_anims.py. The clips
 * are retargeted onto the ped at load — see _rotOnly for why that is safe at any carrier scale.
 */
import * as THREE from '../vendor/three.module.js';
import { clone as skeletonClone } from '../vendor/addons/utils/SkeletonUtils.js';
import { GLTFLoader } from '../vendor/addons/loaders/GLTFLoader.js';
import { PlayerSim, STATES, TUNE, clipFor, onLadder } from './player.sim.js';
import { createLadder } from './ladder.js';
import { createBroom } from './broom.js';

export { PlayerSim, STATES, TUNE, clipFor, onLadder, createLadder, createBroom };

export const CHAR_URL = './models/player_01.glb';
export const ANIM_URL = './models/player_anims.glb';

/**
 * Canonicalise the Mixamo skeleton namespace (mixamorig4: vs mixamorig12:) so any clip binds to any
 * character. Every Mixamo auto-rig upload gets its own numbered namespace and three.js binds tracks
 * BY NODE NAME — a mismatched clip binds to nothing and plays a silent T-pose, with no error.
 * (Straight from 90sDJsim index.html. The \d+ is deliberate: a bare "mixamorig:" is already canonical.)
 */
const _canon = (s) => s.replace(/mixamorig\d+/g, 'mixamorig');

const canonRig = (r) => {
  if (!r) return r;
  if (r.scene) r.scene.traverse((o) => { o.name = _canon(o.name); });
  if (r.anims) r.anims.forEach((a) => a.tracks.forEach((t) => { t.name = _canon(t.name); }));
  return r;
};

/**
 * Shared-clip filter: keep limb/spine rotations only. Drop ALL position tracks (a different-scale
 * source inflates or crumples the target) AND Hips.quaternion (a different-orientation source lays
 * the target flat). Quaternions are scale-invariant, which is the whole reason a clip carrier at any
 * scale retargets cleanly onto the metre-scale ped.
 *
 * The cost is that a clip can no longer lie the body down — so the knockdown does NOT come from the
 * Falling clip's root. player.sim.js pitches the root itself, which is also how the fall gets to go
 * DOWNWIND of the gust that caused it. The clip only supplies the flail.
 */
const _rotOnly = (c) => new THREE.AnimationClip(c.name, c.duration,
  c.tracks.filter((t) => t.name.endsWith('.quaternion') && !/Hips\.quaternion$/i.test(t.name)));

const _loadGLTF = (loader, url) => new Promise((res, rej) =>
  loader.load(url, (g) => res({ scene: g.scene, anims: g.animations }), undefined,
    () => rej(new Error(`player: failed to load ${url}`))));

const UP = new THREE.Vector3(0, 1, 0);

const _clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v);

/**
 * Build the player's collision test out of `world.solids` (contracts World).
 *
 * Shape of the problem, measured in the real yard rather than assumed:
 *   · `fence` is a GROUP of 37 child meshes whose combined box is the whole 30×20 m yard — so one
 *     box per entry in solids is useless. We flatten to leaf meshes and box each one.
 *   · the house ROOF is a solid spanning y 2.99–3.21, i.e. entirely above a 1.72 m head. A flat
 *     footprint test would wall off the eaves, so every box is filtered by vertical overlap with
 *     the body and the roof simply drops out.
 * Solids are static, so the boxes are computed once. ~44 leaves, distance-pruned — no raycast per
 * frame. (Lane A's note: the ground is deliberately NOT in solids; heightAt covers it.)
 *
 * @param {object} world  contracts World
 * @param {object} [opts] {radius} metres, the player's shoulder radius
 * @returns {(x:number,z:number,feetY:number,headY:number)=>{x:number,z:number}}
 */
export function makeSolidCollider(world, opts = {}) {
  const radius = opts.radius ?? 0.3;
  const boxes = [];
  const b = new THREE.Box3();
  for (const root of (world && world.solids) || []) {
    root.updateWorldMatrix(true, true);
    root.traverse((o) => {
      if (!o.isMesh) return;
      b.setFromObject(o);
      if (!isFinite(b.min.x)) return;
      boxes.push({ x0: b.min.x, x1: b.max.x, z0: b.min.z, z1: b.max.z, y0: b.min.y, y1: b.max.y });
    });
  }
  const out = { x: 0, z: 0 };   // scratch — copied by the caller immediately, never retained
  const r2 = radius * radius;

  return function collide(x, z, feetY, headY) {
    out.x = x; out.z = z;
    for (let i = 0; i < boxes.length; i++) {
      const bx = boxes[i];
      if (bx.y1 <= feetY + 0.05 || bx.y0 >= headY) continue;        // under the eaves / over a low wall
      // closest point on the box to the body centre, in XZ
      const cx = _clamp(out.x, bx.x0, bx.x1), cz = _clamp(out.z, bx.z0, bx.z1);
      const dx = out.x - cx, dz = out.z - cz;
      const d2 = dx * dx + dz * dz;
      if (d2 >= r2) continue;                                        // clear
      if (d2 > 1e-10) {                                              // outside: push along the normal
        const d = Math.sqrt(d2);
        out.x = cx + (dx / d) * radius;
        out.z = cz + (dz / d) * radius;
      } else {
        // centre is inside the box (spawned in a wall, or shoved through): eject through the nearest
        // face rather than picking an arbitrary axis, so you pop out the side you came in.
        const l = out.x - bx.x0, rr = bx.x1 - out.x, u = out.z - bx.z0, dn = bx.z1 - out.z;
        const m = Math.min(l, rr, u, dn);
        if (m === l) out.x = bx.x0 - radius;
        else if (m === rr) out.x = bx.x1 + radius;
        else if (m === u) out.z = bx.z0 - radius;
        else out.z = bx.z1 + radius;
      }
    }
    return out;
  };
}

export class PlayerView {
  /**
   * @param {object} rig    {scene, anims} — the character
   * @param {Array}  clips  retargeted AnimationClips
   * @param {number} height metres to the top of the head
   */
  constructor(rig, clips, height = 1.72) {
    // root: world position + facing + knockdown pitch.  fig: the rig, lifted so its feet sit at y=0.
    this.root = new THREE.Group();
    this.root.name = 'player';

    const fig = skeletonClone(rig.scene);
    this.fig = fig;
    fig.traverse((o) => { if (o.isMesh) { o.frustumCulled = false; o.castShadow = true; } });
    this.root.add(fig);
    this.root.updateWorldMatrix(true, true);

    // measure, then scale — never a blind setScalar (DEVMANUAL). /head/i matches both Head and
    // HeadTop_End; max() takes the crown, which is what "height" means.
    const wp = new THREE.Vector3();
    let headY = 0;
    fig.traverse((o) => {
      if (!o.isBone) return;
      o.getWorldPosition(wp);
      if (/head/i.test(o.name)) headY = Math.max(headY, wp.y);
    });
    if (headY > 1e-4) { fig.scale.setScalar(height / headY); fig.updateWorldMatrix(true, true); }
    this.height = height;

    // plant the feet: lift the rig so its lowest bone sits on the root's origin
    let minY = Infinity;
    fig.traverse((o) => { if (o.isBone) { o.getWorldPosition(wp); minY = Math.min(minY, wp.y); } });
    if (minY < Infinity) fig.position.y = -minY;

    this.mixer = new THREE.AnimationMixer(fig);
    this.actions = {};
    // some characters lack bones a shared clip animates (thumb joints, say) — bind only what exists,
    // else three.js spams "No target node found" for every missing bone
    const nodes = new Set();
    fig.traverse((o) => { if (o.name) nodes.add(o.name); });
    for (const clip of clips) {
      const bindable = clip.tracks.filter((t) => nodes.has(t.name.split('.')[0]));
      if (!bindable.length) continue;
      const use = bindable.length === clip.tracks.length
        ? clip : new THREE.AnimationClip(clip.name, clip.duration, bindable);
      this.actions[clip.name] = this.mixer.clipAction(use);
    }
    this.current = null;
    this._axis = new THREE.Vector3();
    this._qYaw = new THREE.Quaternion();
    this._qPitch = new THREE.Quaternion();
    this._leanAxis = new THREE.Vector3();
    this._qLean = new THREE.Quaternion();
  }

  /** @returns {string[]} clip names that bound to at least one bone */
  get clipNames() { return Object.keys(this.actions); }

  play(name, loop = true, fade = 0.18) {
    const next = this.actions[name];
    if (!next || next === this.current) return;
    next.reset();
    next.setEffectiveWeight(1);
    next.setLoop(loop ? THREE.LoopRepeat : THREE.LoopOnce, loop ? Infinity : 1);
    next.clampWhenFinished = !loop;
    if (this.current) next.crossFadeFrom(this.current, fade, false);
    next.play();
    this.current = next;
  }

  /** Push one sim frame onto the rig. dt drives the mixer only — the sim already stepped. */
  sync(sim, dt) {
    const st = STATES[sim.state];
    // clipFor, not st.clip: carrying swaps in Carry/CarryIdle, and an interaction names its own verb
    this.play(clipFor(sim), st.loop !== false);

    // climbY lifts the whole rig up the rungs. Same trick as the knockdown pitch: _rotOnly strips
    // the root from every clip, so ClimbLadder can't raise the body — the sim does, and the clip
    // just supplies the arms and legs.
    this.root.position.set(sim.pos.x, sim.pos.y + (sim.climbY || 0), sim.pos.z);

    // yaw, then tip over about a world-horizontal axis square to the fall direction, pivoting at the
    // feet. At pitch 0 this is exactly the yaw, so upright play is untouched.
    this._qYaw.setFromAxisAngle(UP, sim.facing);
    if (sim.pitch > 1e-4) {
      this._axis.set(sim.knockDir.z, 0, -sim.knockDir.x);
      if (this._axis.lengthSq() < 1e-8) this._axis.set(1, 0, 0);
      this._qPitch.setFromAxisAngle(this._axis.normalize(), sim.pitch * Math.PI * 0.5);
      this.root.quaternion.copy(this._qPitch).multiply(this._qYaw);
    } else if (sim.lean > 1e-3) {
      // Wind lean (SPRINT13): the same foot-pivot tip as the knockdown, but small and toward the
      // wind's downwind bearing (leanDir) rather than the fall direction. The sim suppresses lean
      // whenever pitch > 0, so these two never fight — this is the else branch by construction, not
      // by luck. leanMaxRad is the whole tip; a knockdown's is π/2, so a full lean is ~a third of a
      // fall. Yaw still applies underneath, so you lean while facing wherever you're walking.
      this._leanAxis.set(sim.leanDir.z, 0, -sim.leanDir.x);
      if (this._leanAxis.lengthSq() < 1e-8) this._leanAxis.set(1, 0, 0);
      this._qLean.setFromAxisAngle(this._leanAxis.normalize(), sim.lean * sim.leanMaxRad);
      this.root.quaternion.copy(this._qLean).multiply(this._qYaw);
    } else {
      this.root.quaternion.copy(this._qYaw);
    }

    this.mixer.update(dt);
  }

  dispose() {
    this.mixer.stopAllAction();
    this.root.removeFromParent();
  }
}

/**
 * Load the player and attach it to a scene.
 * @param {THREE.Scene|THREE.Object3D} scene
 * @param {object} [opts]  {start, facing, height, groundAt, tune, charUrl, animUrl}
 * @returns {Promise<{sim: PlayerSim, view: PlayerView, step: function}>}
 */
export async function loadPlayer(scene, opts = {}) {
  const loader = new GLTFLoader();
  const [rig, animPack] = await Promise.all([
    _loadGLTF(loader, opts.charUrl || CHAR_URL),
    _loadGLTF(loader, opts.animUrl || ANIM_URL),
  ]);
  canonRig(rig);
  canonRig(animPack);

  const clips = animPack.anims.map(_rotOnly).filter((c) => c.tracks.length);
  const view = new PlayerView(rig, clips, opts.height || 1.72);
  scene.add(view.root);

  const sim = new PlayerSim(opts);

  const missing = Object.values(STATES).map((s) => s.clip).filter((c, i, a) =>
    a.indexOf(c) === i && !view.actions[c]);
  if (missing.length) console.warn('player: state machine wants clips that did not bind:', missing);

  return {
    sim,
    view,
    /** Convenience: step the sim then push it to the rig. */
    step(dt, t, input, wind) {
      sim.step(dt, t, input, wind);
      view.sync(sim, dt);
      return sim.state;
    },
  };
}

/**
 * The `Player` contract factory (contracts.js) — this is the one main.js calls.
 * Drop-in for createPlaceholderPlayer(scene, world, cameraRig): same first three args, so boot()
 * only changes which function it calls. Everything the player needs per frame it pulls itself, so
 * `update(dt, t)` matches the contract's signature exactly.
 *
 * @param {THREE.Object3D} scene
 * @param {object} world      contracts World — `heightAt(x,z)` clamps the player to the ground
 * @param {object} cameraRig  contracts Camera — `yaw` is what WASD is relative to
 * @param {object} [opts]     {wind, interact, start, facing, height, tune, charUrl, animUrl}
 * @returns {Promise<object>} satisfies checkContract('player', …)
 */
export async function createPlayer(scene, world, cameraRig, opts = {}) {
  const height = opts.height || 1.72;
  const p = await loadPlayer(scene, {
    ...opts,
    height,
    groundAt: world && world.heightAt ? (x, z) => world.heightAt(x, z) : undefined,
    // built AFTER the world exists so the boxes capture E's real GLBs, not the graybox
    collide: opts.collide !== undefined ? opts.collide : makeSolidCollider(world, opts),
    start: opts.start || { x: 0, y: 0, z: 6 },
  });
  const keyboard = new KeyboardInput();
  const { sim, view } = p;

  // The ladder self-wires from here rather than from main.js: createPlayer is already handed the
  // scene, the world and interact, which is everything it needs — so Lane A's file doesn't have to
  // change to get a whole sub-system. Opt out with {ladder: false} if a harness doesn't want it.
  const ladder = (opts.ladder === false || !opts.interact)
    ? null : createLadder(scene, world, opts.interact, sim);
  // The broom needs the sail rig, which createPlayer isn't handed — but wireYardActions is, and
  // main.js re-calls it through rigSail() whenever attach() swaps the rig. So read it live off
  // interact rather than capturing a rig that's about to be replaced.
  const broom = (opts.broom === false || !opts.interact)
    ? null : createBroom(scene, world, opts.interact, sim, () => opts.interact.sailRig);

  return {
    get pos() { return sim.pos; },
    get carrying() { return sim.carrying; },
    set carrying(v) { sim.carrying = v; },
    get busy() { return sim.busy; },

    /** @param {number} dt @param {number} t — main.js's fixed-dt loop drives this */
    update(dt, t) {
      const input = keyboard.read(cameraRig ? cameraRig.yaw || 0 : 0);
      sim.step(dt, t, input, opts.wind);
      if (ladder) ladder.update(dt, t, input);
      if (broom) broom.update(dt, t, input);
      if (opts.interact) opts.interact.step(dt, t, sim, keyboard.holding);
      view.sync(sim, dt);
    },

    /** The Object3D to follow/frame. Lane A's camera wants this, not the raw rig. */
    get object() { return view.root; },
    sim,
    view,
    ladder,
    broom,
    keyboard,
    dispose() {
      keyboard.dispose(); view.dispose();
      if (ladder) ladder.dispose();
      if (broom) broom.dispose();
    },
  };
}

/**
 * Keyboard → the sim's input shape. Camera yaw comes from Lane A's camera each frame.
 * Kept out of PlayerSim so the sim stays headless.
 */
export class KeyboardInput {
  constructor(target = window) {
    this.keys = new Set();
    this._down = (e) => {
      this.keys.add(e.code);
      if (/^(Arrow|Space)/.test(e.code)) e.preventDefault();
    };
    this._up = (e) => this.keys.delete(e.code);
    target.addEventListener('keydown', this._down);
    target.addEventListener('keyup', this._up);
    this._target = target;
  }

  get holding() { return this.keys.has('KeyE'); }

  /** @param {number} camYaw radians */
  read(camYaw = 0) {
    const k = this.keys;
    const x = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0);
    const z = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0);
    return {
      x, z, camYaw,
      run: k.has('ShiftLeft') || k.has('ShiftRight'),
      shelter: k.has('KeyC'),          // hold to brace — see STATES.shelter
    };
  }

  dispose() {
    this._target.removeEventListener('keydown', this._down);
    this._target.removeEventListener('keyup', this._up);
  }
}
