// flight/player.js (Lane B) — ENDO-1's controller, camera rig and durability.
//
// THE MODEL (GDD "flow-locked hybrid", tube mode). The player does not live in world space;
// it lives in the world's spline space and is rendered into world space each frame:
//
//     s      arclength down the canal. Peristalsis owns it: ds/dt = flow·(throttle+surf+boost).
//            You can lean on it ±40%, burst past it, or surf it. You cannot stop or reverse.
//     x, y   the cross-section disc offset — x along frame.bin (lateral), y along frame.nor
//            (vertical). This is where ALL your agency is, and it is fully analog.
//
//     worldPos = frame.pos + frame.nor·y + frame.bin·x        (frame = world.sample(s))
//     theta    = atan2(x, y)   — matches world.project()'s atan2(rel·bin, rel·nor)
//
// Everything downstream falls out of this: collision is one radius compare against
// world.wallRho, the camera is the same three numbers lagged, and enemies chase in the same
// space for pennies. Arena/6DOF mode is round 2 (see NOTES); modeAt() is guarded below.

import * as THREE from 'three';
import { TUNING as T } from './tuning.js';
import { createInput } from './input.js';
import { buildShip, buildReticle } from './ship.js';

export function createPlayer({ scene, world, bus, rng, flags = {}, assets = null, camera, dom }) {
  const input = createInput(dom);
  const ship = buildShip(assets);
  const reticle = buildReticle();
  scene.add(ship.group, reticle.mesh);

  const st = {
    s: 2, x: 0, y: 0,             // spline-space position
    vx: 0, vy: 0,                 // disc velocity (u/s)
    ax: 0, ay: 0,                 // aim offset in the disc plane at s + aim.distance
    throttle: 1,
    boostT: 0, boostCd: 0, boostBlend: 0, iframeT: 0,
    surfBlend: 0, onCrest: false,
    coat: T.coat.max, hull: T.hull.max, sinceDamage: 99, wallCd: 0,
    roll: 0, pitch: 0,
    speed: 0, alive: true,
  };

  // scratch — this runs 60×/s; nothing in the loop allocates
  const _pos = new THREE.Vector3(), _aim = new THREE.Vector3(), _look = new THREE.Vector3();
  const _aimLag = new THREE.Vector3(), _camPos = new THREE.Vector3(), _tmp = new THREE.Vector3();
  let aimLagInit = false;

  const discToWorld = (out, frame, x, y) =>
    out.copy(frame.pos).addScaledVector(frame.nor, y).addScaledVector(frame.bin, x);

  const clampS = (s) => THREE.MathUtils.clamp(s, 0, world.length);

  // --- durability -------------------------------------------------------------------
  // Discrete hits emit player:damage. Continuous drain (biome coatDrain, enemy auras) does
  // NOT — it would spam the bus 60×/s and E would render a permanent damage flash. Lane E
  // reads continuous state off player:state instead. Auras tick discretely (combat/enemies).
  function damage(amount, kind = 'hit') {
    if (!st.alive || st.iframeT > 0 || amount <= 0) return false;
    st.sinceDamage = 0;
    const toCoat = Math.min(st.coat, amount);
    st.coat -= toCoat;
    const leak = amount - toCoat;
    if (leak > 0) st.hull = Math.max(0, st.hull - leak);
    bus.emit('player:damage', { amount, kind });
    bus.emit('audio:cue', { name: leak > 0 ? 'hull_hit' : 'coat_hit' });
    if (st.hull <= 0 && st.alive) { st.alive = false; bus.emit('player:death', { s: st.s }); }
    return true;
  }

  function drain(amount) {           // continuous, silent — coat only, never kills
    if (!st.alive || amount <= 0) return;
    st.coat = Math.max(0, st.coat - amount);
  }

  // --- the loop ---------------------------------------------------------------------
  function update(dt) {
    const intent = input.sample();
    const mouse = input.takeAim();
    const biome = world.biomeAt(st.s);

    if (!st.alive) { updateCamera(dt, world.sample(st.s)); return; }

    // throttle: a lean, springing back to neutral
    if (intent.throttle) st.throttle += intent.throttle * T.flow.throttleRate * dt;
    else st.throttle += (1 - st.throttle) * Math.min(1, T.flow.throttleReturn * dt * 4);
    st.throttle = THREE.MathUtils.clamp(st.throttle, T.flow.throttleMin, T.flow.throttleMax);

    // boost
    st.wallCd = Math.max(0, st.wallCd - dt);
    st.boostCd = Math.max(0, st.boostCd - dt);
    st.boostT = Math.max(0, st.boostT - dt);
    st.iframeT = Math.max(0, st.iframeT - dt);
    if (intent.boost && st.boostCd <= 0) {
      st.boostT = T.boost.duration;
      st.boostCd = T.boost.cooldown;
      st.iframeT = T.boost.iframes;
      bus.emit('player:boost', { s: st.s });
      bus.emit('audio:cue', { name: 'boost' });
    }
    const boostTarget = st.boostT > 0 ? 1 : 0;
    st.boostBlend += (boostTarget - st.boostBlend) * Math.min(1, (boostTarget ? 14 : 3.5) * dt);

    // surf: ride the peristalsis crest. flowPulse is Lane A's JS mirror of the wall's vertex
    // displacement — see NOTES, it's charter-specified but missing from the TECH contract.
    const pulse = world.flowPulse ? world.flowPulse(st.s) : 0;
    st.onCrest = pulse > T.surf.threshold;
    const surfTarget = st.onCrest ? 1 : 0;
    if (Math.abs(surfTarget - st.surfBlend) > 0.001) {
      const was = st.surfBlend > 0.5;
      st.surfBlend += (surfTarget - st.surfBlend) * Math.min(1, T.surf.ramp * dt);
      if (was !== st.surfBlend > 0.5) bus.emit('player:surf', { active: st.surfBlend > 0.5, s: st.s });
    }

    // forward motion — the flow carries you; you only ever scale it
    st.speed = biome.flow * (st.throttle + st.surfBlend * T.surf.gain + st.boostBlend * T.boost.gain);
    st.s = clampS(st.s + st.speed * dt);

    // disc movement
    st.vx += intent.disc.x * T.disc.accel * dt;
    st.vy += intent.disc.y * T.disc.accel * dt;
    const damp = Math.exp(-T.disc.damping * dt);
    st.vx *= damp; st.vy *= damp;
    const sp = Math.hypot(st.vx, st.vy);
    if (sp > T.disc.maxSpeed) { st.vx *= T.disc.maxSpeed / sp; st.vy *= T.disc.maxSpeed / sp; }
    st.x += st.vx * dt;
    st.y += st.vy * dt;

    const frame = world.sample(st.s);
    discToWorld(_pos, frame, st.x, st.y);
    collideWall(frame);

    // aim
    const aimIn = {
      x: mouse.x * T.aim.sensitivity + intent.aimStick.x * T.aim.padSpeed * dt,
      y: -mouse.y * T.aim.sensitivity + intent.aimStick.y * T.aim.padSpeed * dt,
    };
    st.ax += aimIn.x; st.ay += aimIn.y;
    if (!aimIn.x && !aimIn.y) {                       // hands off: drift back to centre
      const k = Math.min(1, T.aim.recenter * dt);
      st.ax -= st.ax * k; st.ay -= st.ay * k;
    }
    const ar = Math.hypot(st.ax, st.ay);
    if (ar > T.aim.range) { st.ax *= T.aim.range / ar; st.ay *= T.aim.range / ar; }

    const aimFrame = world.sample(clampS(st.s + T.aim.distance));
    discToWorld(_aim, aimFrame, st.ax, st.ay);
    reticle.mesh.position.copy(_aim);
    if (camera) reticle.mesh.lookAt(camera.position);
    reticle.setHot(intent.fire ? 1 : 0);

    // the ship's nose chases the reticle rather than snapping to it (Star Fox lag)
    if (!aimLagInit) { _aimLag.copy(_aim); aimLagInit = true; }
    _aimLag.lerp(_aim, Math.min(1, T.aim.lag * dt));

    // coat: biome ambient drain, then delayed regen
    st.sinceDamage += dt;
    drain((biome.coatDrain ?? 0) * dt);
    if (st.sinceDamage > T.coat.regenDelay)
      st.coat = Math.min(T.coat.max, st.coat + T.coat.regen * dt);

    poseShip(dt, frame);
    updateCamera(dt, frame);
    emitState(biome);
  }

  // Arcade wall response: shove + graze damage, never a hard stop. Forward motion is the
  // flow's and is untouched — scraping a fold costs coat and control, not your run.
  function collideWall(frame) {
    const hit = world.collide(_pos, T.ship.radius);
    if (!hit) return;

    const rho = Math.hypot(st.x, st.y);
    if (rho < 1e-5) return;
    const ux = st.x / rho, uy = st.y / rho;         // outward radial, in disc coords

    st.x -= ux * hit.push.length();                  // back inside the wall
    st.y -= uy * hit.push.length();

    const vr = st.vx * ux + st.vy * uy;              // >0 = travelling into the wall
    if (vr > 0) {
      st.vx -= (1 + T.wall.restitution) * vr * ux;
      st.vy -= (1 + T.wall.restitution) * vr * uy;
    }
    st.vx -= ux * T.wall.shoveOut;
    st.vy -= uy * T.wall.shoveOut;

    if (vr > T.wall.grazeFloor && st.wallCd <= 0) {
      damage(Math.min(T.wall.maxHit, (vr - T.wall.grazeFloor) * T.wall.damagePerSpeed), hit.kind);
      st.wallCd = T.wall.cooldown;
      bus.emit('audio:cue', { name: 'wall_scrape' });
    }
    discToWorld(_pos, frame, st.x, st.y);
  }

  function poseShip(dt, frame) {
    const rollTarget = THREE.MathUtils.clamp(-st.vx * T.bank.gain, -T.bank.max, T.bank.max);
    st.roll += (rollTarget - st.roll) * Math.min(1, T.bank.rate * dt);
    st.pitch += (st.vy * T.bank.pitchGain - st.pitch) * Math.min(1, T.bank.rate * dt);

    ship.group.position.copy(_pos);
    ship.group.up.copy(frame.nor);
    ship.group.lookAt(_aimLag);          // hull is authored nose-toward −Z, so this aims it
    ship.group.rotateZ(st.roll);         // roll about the view axis = bank
    ship.group.rotateX(st.pitch);
    ship.setThrust(THREE.MathUtils.clamp((st.throttle - 0.6) / 0.8, 0, 1) * 0.6 + st.boostBlend * 0.4);
  }

  // Chase cam on the parallel-transport frame. up = frame.nor, ALWAYS — never world-up.
  // If this ever rolls or snaps, the frame is the suspect, not this function (charter: file
  // it to Lane A rather than patching here).
  const cam = { s: 0, x: 0, y: 0, fov: T.cam.fov, init: false };
  function updateCamera(dt, frame) {
    if (!camera) return;
    if (!cam.init) { cam.s = st.s - T.cam.back; cam.x = st.x; cam.y = st.y; cam.init = true; }

    cam.s = clampS(st.s - T.cam.back);
    const k = Math.min(1, T.cam.discLag * dt);
    cam.x += (st.x * T.cam.discFollow - cam.x) * k;
    cam.y += (st.y * T.cam.discFollow - cam.y) * k;

    const cf = world.sample(cam.s);
    discToWorld(_camPos, cf, cam.x, cam.y + T.cam.up);
    camera.position.copy(_camPos);

    const lf = world.sample(clampS(st.s + T.cam.lookAhead));
    discToWorld(_look, lf, st.x * 0.5, st.y * 0.5);
    _look.lerp(_tmp.copy(_aimLag), T.cam.aimInfluence);
    camera.up.copy(frame.nor);
    camera.lookAt(_look);

    const fovTarget = T.cam.fov + st.boostBlend * T.boost.fovKick;
    if (Math.abs(fovTarget - cam.fov) > 0.01) {
      cam.fov += (fovTarget - cam.fov) * Math.min(1, T.cam.fovRate * dt);
      camera.fov = cam.fov;
      camera.updateProjectionMatrix();
    }
  }

  // Lane E's HUD is bus-driven only, so continuous state has to arrive as an event. One
  // small object per frame; treat it as read-only, don't retain it.
  function emitState(biome) {
    bus.emit('player:state', {
      coat: st.coat, coatMax: T.coat.max,
      hull: st.hull, hullMax: T.hull.max,
      speed: st.speed, flow: biome.flow, throttle: st.throttle,
      boostReady: st.boostCd <= 0, boostCd: st.boostCd, boostCdMax: T.boost.cooldown,
      surfing: st.surfBlend > 0.5, iframes: st.iframeT > 0,
      s: st.s, length: world.length, progress: world.length ? st.s / world.length : 0,
      biome: biome.id, alive: st.alive,
    });
  }

  bus.emit('player:spawn', { s: st.s });

  return {
    update,
    state: st,
    // combat/ reads this for fire/fire2. Safe because input.sample() is called exactly once
    // per frame, here, and boot runs player.update() before combat.update() — so the
    // edge-triggered flags (fire2) are still live when the weapons read them this frame.
    get intent() { return input.intent; },
    get _input() { return input; },        // harness only (flight/dev.js) — see input.js._debug
    get position() { return _pos; },
    get aimPoint() { return _aim; },
    get radius() { return T.ship.radius; },
    get forward() { return world.sample(st.s).tan; },
    damage,
    drain,
    respawn(s = 0) {
      Object.assign(st, { s, x: 0, y: 0, vx: 0, vy: 0, coat: T.coat.max, hull: T.hull.max, alive: true });
      cam.init = false; aimLagInit = false;
      bus.emit('player:spawn', { s });
    },
    dispose() {
      scene.remove(ship.group, reticle.mesh);
      ship.dispose(); reticle.dispose(); input.dispose();
    },
  };
}
