/**
 * player.sim.js — the small person's deterministic core.  (Lane D)
 *
 * Zero imports, on purpose. PLAN3D §0 requires the sim to be fast-forwardable in selftest with a
 * fixed dt and no renderer, so nothing in here touches THREE, the DOM, rAF, Date.now() or
 * Math.random(). `step(dt, t, …)` is the entire clock: same inputs → same trace, every run.
 *
 * player.js owns the view (rig, clips, camera). This file owns the truth: where the person is,
 * what state they're in, and what the wind is doing to them.
 */

/**
 * The state machine, as a table (PLAN3D §5-D.5 asks for a table test).
 *  clip      — clip name in player_anims.glb
 *  carryClip — clip to use instead when the player has something in their hands
 *  locked    — movement input is ignored, and `player.busy` is true
 *  secs      — timed states auto-advance to `next` after this long
 * Invariant the selftest enforces: every locked state either has a `next` (so it drains on its own)
 * or names an external releaser. `busy` and `shelter` are the released ones — interact.js and the
 * shelter key each both ENTER and LEAVE their own state, so a dropped release can't strand you.
 */
export const STATES = {
  idle:    { clip: 'Idle',          carryClip: 'CarryIdle', locked: false, loop: true },
  walk:    { clip: 'Walk',          carryClip: 'Carry',     locked: false, loop: true },
  run:     { clip: 'Run',           carryClip: 'Carry',     locked: false, loop: true },
  busy:    { clip: 'Idle',          locked: true,  loop: true, releasedBy: 'interact' },
  shelter: { clip: 'TakeCover',     locked: true,  loop: true, releasedBy: 'input' },
  stumble: { clip: 'StumbleBack',   locked: true,  loop: false, secs: 0.8, next: 'idle' },
  stagger: { clip: 'Reaction',      locked: true,  loop: false, secs: 0.9, next: 'idle' },
  knocked: { clip: 'Falling',       locked: true,  loop: false, secs: 1.4, next: 'getup' },
  getup:   { clip: 'CrouchToStand', locked: true,  loop: false, secs: 1.3, next: 'idle' },

  // --- the ladder (decision 12). climbY is driven in code and the clip plays on top, exactly the
  // knockdown precedent: _rotOnly strips the root, so ClimbLadder can no more lift the body than
  // Falling could lay it down. ladder.js calls climbTo(); the sim owns the height. ---
  climb:   { clip: 'ClimbLadder',   locked: true,  loop: true, releasedBy: 'ladder' },
  // atTop is deliberately NOT locked: `busy` gates interact.js, and the whole point of being up
  // there is that hold-E works. Movement is stopped by `onLadder` instead, not by `locked`.
  atTop:   { clip: 'Idle',          carryClip: 'CarryIdle', locked: false, loop: true, releasedBy: 'ladder' },
};

/**
 * True while the player is on a ladder — movement is off, and the wind is meaner.
 *
 * Keyed on HEIGHT, not on state, and that distinction is load-bearing: hold-E puts you into `busy`,
 * so a state-based test would say you'd stepped off the ladder the instant you started the repair
 * you climbed up to do. (It did exactly that — the gate cancelled its own hold.) Height is the
 * physical truth and survives every state the ladder passes through.
 */
export const onLadder = (sim) => sim.climbY > 0.02 || sim.state === 'climb';

/**
 * Which clip a state actually plays right now. Carrying swaps the locomotion set (Carry/CarryIdle),
 * and an interaction can name its own verb (`Crank` at a turnbuckle, `PickUp` at the shed table) —
 * interact.js writes that into `sim.busyClip`. Everything else is the table's `clip`.
 * Kept here rather than in player.js so the selftest can assert it without a renderer.
 */
export function clipFor(sim) {
  const st = STATES[sim.state];
  if (sim.state === 'busy' && sim.busyClip) return sim.busyClip;
  if (sim.carrying && st.carryClip) return st.carryClip;
  return st.clip;
}

/**
 * Tuning. Ported from the 2D prototype's shape (prototype/game.js:250-252), retuned to metres and
 * m/s per PLAN3D §1 ("port the behaviour, retune the constants").
 */
export const TUNE = {
  walkSpeed: 1.5,       // m/s — a person crossing a 30 m yard, unhurried
  runSpeed: 4.4,        // m/s — shift
  accel: 16,            // m/s² toward the wanted velocity
  turnRate: 11,         // rad/s — facing chases the movement direction

  // prototype: slow = 1 - min(0.35, wind.speed / 160). Its wind ran ~4 (calm) to ~38 (gust peak),
  // which is already m/s-shaped, so the curve ports across directly.
  slowMax: 0.35,
  slowRef: 160,

  // prototype: push = ws*ws*0.55 — "wind pressure goes with speed², gusts have teeth"; and shove
  // only applied while wind.gust > 8, never from the base wind.
  shoveK: 0.0035,       // shove accel (m/s²) = shoveK · ws²  → ~3.2 m/s² in a 30 m/s gust
  // Gate RETUNED against the real storm_02 (see the header note): the prototype's literal 8 was a
  // low bar in ITS units (gusts ran 12–38, so 8 was "almost always"); ported straight across it
  // became a high bar in ours (gusts peak at 12.1) and threw away most of the shove. 4 restores the
  // prototype's intent — you're being pushed for ~26 s of a 90 s storm, and a calm day is still calm.
  shoveGustMin: 4,      // m/s of gust (over baseline) before the wind can push you at all
  shoveDamp: 2.5,       // 1/s foot-friction bleed → terminal drift ≈ shoveK·ws²/shoveDamp

  // Baseline tracker: contracts.js exposes wind.sample() (total) and wind.gustTelegraph() (before
  // the gust), but nothing reports gust magnitude DURING the hold. Rather than widen Lane C's
  // contract, we recover it: a slow EMA of local wind speed is the base curve, and everything above
  // it is gust. Self-calibrating to whatever storm JSON Lane C authors.
  baseTrack: 0.25,      // 1/s — ~4 s memory; gust holds are ~1.7 s, so they read as gust, not base

  // Knockdown mirrors the sail's failure rule (PLAN3D §1: break after 0.4 s SUSTAINED overload) —
  // same verb for cloth and for people, so the player reads one language.
  knockWind: 30,        // m/s sustained local wind → you go down
  knockSustain: 0.5,    // s above knockWind before it happens
  knockBleed: 2,        // exposure drains this many × faster than it fills
  pitchSecs: 0.35,      // s for the body to swing down / back up (view reads sim.pitch)

  // Wind lean (SPRINT13 gate 2.4). The QA pass: "the player stands bolt upright and unbothered" at
  // 65 km/h. E rendered the two Mixamo lean candidates and rejected both on screen (a couch-shove
  // and a bus-stop slouch), and proved the deeper reason a clip can't do this at all: _rotOnly drops
  // Hips.quaternion from every clip at load (player.js:44), so a baked lean is discarded — and a
  // clip's lean axis is fixed while the wind's bearing swings. So the lean is the ROOT, same machine
  // as the knockdown pitch: the sim owns an angle and a direction, the view tips the body by it.
  // Keyed on windBase (the ~4 s EMA), NOT raw windSpeed — measured: raw strobes the posture 8-18×/min
  // across the storms, windBase settles it to 0-2 changes per 90 s. Sustained wind = posture; gust is
  // already the stumble/knock event. Bands land on the storm rating ladder (D's THREADS table): night
  // 1 upright all night, 2-3 lean, 4-5 in the gale.
  leanWindMin: 8.3,     // m/s windBase (30 km/h — the speed C's leaves start at) before you lean at all
  leanWindFull: 22,     // m/s windBase for a full lean — past the wildnight median, shy of its 25 peak
  leanMaxRad: 0.30,     // rad (~17°) of torso tip at full lean. A brace-into-it, not a fall (pitch is 0.5π)
  leanSecs: 0.6,        // s to reach/leave the target lean — slow, so a lull visibly lets you straighten

  // A gust that can't floor you can still break your stride. Sits BELOW knockWind on purpose, so a
  // storm reads as: shoved → stumbling → floored, rather than fine-fine-fine-flat-on-your-back.
  // RETUNED against the real storm_02: 17 was set against a mock that injected +18 gusts, but the
  // real storm's gusts-over-baseline peak at 12.1, so StumbleBack was unreachable — dead code in the
  // shipped game. 9 catches the top third of the 12 gusts in a wild night: 4 stumbles to 2
  // knockdowns, which is the intended rhythm (stumble is the beat, knockdown the punchline).
  // d.test.js pins this to the actual storm JSON so a reweighted downdraft can't quietly kill it again.
  stumbleGust: 9,       // m/s over baseline → you lose your footing (but not your feet)
  stumbleCooldown: 3,   // s — punctuation, not a stutter: one gust hold must not stumble you twice

  // Shelter (hold C): brace and the wind stops owning you. This is the storm's real answer to "the
  // gusts are too strong to cross the yard" — wait one out, then move in the lull.
  shelterKnockMult: 2.0,  // knockWind × this while braced — a gust that floors you standing won't
  shelterShoveMult: 0.25, // and it barely pushes you

  // Ladder (decision 12). DESIGN.md: "ladder work at height in wind is genuinely tense" — this is
  // where that gets teeth. You cannot brace up there (both hands are on the rungs), so the only
  // defence is choosing your moment: climb in a lull, not through a gust.
  climbRate: 1.1,         // m/s up or down a rung — slow enough that the storm gets a vote
  reach: 2.2,             // m — how high a 1.72 m person's hands get, standing. Fascia sits at 2.48.
  ladderKnockMult: 0.6,   // knockWind × this while on the ladder — far easier to be blown off
};

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

/** Shortest-arc angle step from a toward b, at most `maxStep` radians. */
function turnToward(a, b, maxStep) {
  let d = (b - a) % (Math.PI * 2);
  if (d > Math.PI) d -= Math.PI * 2;
  if (d < -Math.PI) d += Math.PI * 2;
  return a + clamp(d, -maxStep, maxStep);
}

export class PlayerSim {
  /**
   * @param {object}   [opts]
   * @param {object}   [opts.start]     {x,y,z} spawn, metres
   * @param {function} [opts.groundAt]  (x,z) -> y. Lane A's world.js provides the real one.
   * @param {function} [opts.collide]   (x,z,feetY,headY) -> {x,z} pushed clear of world.solids.
   *   Injected, not imported, for the same reason as groundAt: this file must stay renderer-free.
   *   player.js#makeSolidCollider builds the real one out of world.solids.
   * @param {number}   [opts.height]    body height, metres — the collider's vertical span
   * @param {object}   [opts.tune]      overrides for TUNE
   */
  constructor(opts = {}) {
    const s = opts.start || { x: 0, y: 0, z: 0 };
    this.pos = { x: s.x, y: s.y || 0, z: s.z };
    this.vel = { x: 0, z: 0 };          // intended (input-driven) velocity
    this.shove = { x: 0, z: 0 };        // wind-driven velocity, decays through foot friction
    this.facing = opts.facing || 0;     // yaw; models face +Z at 0 (90sDJsim convention)

    this.state = 'idle';
    this.stateT = 0;
    this.carrying = null;               // contract: player.carrying — one item, hands-full rule
    this.busyClip = null;               // interact.js names the verb for the current hold (Crank, PickUp…)
    this.stumbleCool = 0;               // s until a gust may stumble you again
    this.events = [];                   // {type:'state'|'drop'|'knockdown', …} drained by the view/HUD

    this.exposure = 0;                  // s spent above knockWind
    this.windBase = 0;                  // EMA baseline (see TUNE.baseTrack)
    this.windSpeed = 0;                 // last sampled local speed, m/s — HUD/audio read this
    this.gust = 0;                      // windSpeed - windBase, clamped ≥0
    this.pitch = 0;                     // 0 upright … 1 flat on the ground
    this.knockDir = { x: 0, z: 1 };     // which way the body went down
    this.lean = 0;                      // 0 upright … 1 full wind-lean (view reads this + leanDir)
    this.leanDir = { x: 0, z: 1 };      // unit downwind — the way the wind is trying to push you

    this.climbY = 0;                    // m above the ground; >0 means you're up a ladder
    this.climbTarget = 0;               // where ladder.js asked you to be
    this.fellFrom = 0;                  // m — height of the last fall, for the HUD/aftermath to shame you with

    this.groundAt = opts.groundAt || (() => 0);
    this.collide = opts.collide || null;
    this.bodyHeight = opts.height || 1.72;
    this.tune = { ...TUNE, ...(opts.tune || {}) };
  }

  /** contract: player.busy */
  get busy() { return !!STATES[this.state].locked; }
  get clip() { return STATES[this.state].clip; }
  get speed() { return Math.hypot(this.vel.x, this.vel.z); }

  /** Radians of torso tip at a full wind-lean. Surfaced so PlayerView reads it, not TUNE. */
  get leanMaxRad() { return this.tune.leanMaxRad; }

  /** How high this person's hands get right now. The gate on reaching a fascia bracket. */
  get reachY() { return this.pos.y + this.climbY + this.tune.reach; }

  /**
   * Go up or down a ladder. ladder.js owns WHERE (it knows the rungs); the sim owns the motion, so
   * a climb is deterministic and fast-forwards in selftest like everything else.
   * @param {number} y  target height above ground; 0 climbs back down
   */
  climbTo(y, t = 0) {
    this.climbTarget = Math.max(0, y);
    if (Math.abs(this.climbTarget - this.climbY) > 0.02) {
      this.vel.x = this.vel.z = 0;
      this.setState('climb', t);
    }
    return true;
  }

  setState(s, t = 0) {
    if (this.state === s) return false;
    if (!STATES[s]) throw new Error(`player: unknown state ${s}`);
    this.state = s;
    this.stateT = 0;
    this.events.push({ type: 'state', state: s, t });
    return true;
  }

  /** Drop whatever's carried (knockdown does this per PLAN3D §5-D.3). */
  drop(t = 0) {
    if (!this.carrying) return null;
    const item = this.carrying;
    this.carrying = null;
    this.events.push({ type: 'drop', item, t });
    return item;
  }

  /** @returns {boolean} true if the pickup was accepted (hands-full rule). */
  pickUp(item, t = 0) {
    if (this.carrying || this.busy) return false;
    this.carrying = item;
    this.events.push({ type: 'pickup', item, t });
    return true;
  }

  /** Light hit — a glancing debris clip. Ignored if already down. */
  staggerHit(t = 0) {
    if (this.state === 'knocked' || this.state === 'getup') return false;
    return this.setState('stagger', t);
  }

  /**
   * Put the player on the ground. debris.js (Lane C) calls this on a solid hit; the sim calls it
   * itself on sustained extreme wind.
   * @param {number} [dirX] @param {number} [dirZ] which way to fall — defaults to downwind/facing.
   */
  knockdown(t = 0, dirX, dirZ) {
    if (this.state === 'knocked' || this.state === 'getup') return false;
    let x = dirX, z = dirZ;
    if (x === undefined || (x === 0 && z === 0)) { x = Math.sin(this.facing); z = Math.cos(this.facing); }
    const m = Math.hypot(x, z) || 1;
    this.knockDir = { x: x / m, z: z / m };
    // Blown off a ladder: you don't stagger, you fall. Everything else about a knockdown is the
    // same, so the ladder gets the get-up chain for free — you just arrive on the ground first.
    this.fellFrom = this.climbY;
    this.climbY = 0;
    this.climbTarget = 0;
    this.setState('knocked', t);
    this.exposure = 0;
    this.vel.x = this.vel.z = 0;
    this.drop(t);
    this.events.push({ type: 'knockdown', t, dir: { ...this.knockDir }, fellFrom: this.fellFrom });
    return true;
  }

  /**
   * @param {number} dt   fixed step, seconds
   * @param {number} t    storm time, seconds
   * @param {object} input {x,z} camera-relative axes in -1..1, {run}, {camYaw} radians
   * @param {object} wind  contracts wind ({sample(pos,t)->Vector3}) or a plain {x,z} vector
   */
  step(dt, t, input = {}, wind = null) {
    const T = this.tune;
    this.stateT += dt;
    this.stumbleCool = Math.max(0, this.stumbleCool - dt);

    // --- local wind, and how much of it is gust ---
    let wx = 0, wz = 0;
    if (wind) {
      const v = typeof wind.sample === 'function' ? wind.sample(this.pos, t) : wind;
      if (v) { wx = v.x || 0; wz = v.z || 0; }
    }
    const ws = Math.hypot(wx, wz);
    this.windSpeed = ws;
    this.windBase += (ws - this.windBase) * clamp(dt * T.baseTrack, 0, 1);
    this.gust = Math.max(0, ws - this.windBase);

    // --- shelter: hold to brace. Enters and leaves itself, so releasing the key always frees you
    //     even mid-gust. Refused while you're down — you can't brace from your back, and refused on
    //     a ladder: both hands are on the rungs. Up there your only defence is having picked a lull. ---
    const up = onLadder(this);
    const wantShelter = !!input.shelter;
    const canShelter = this.state === 'idle' || this.state === 'walk' || this.state === 'run';
    if (wantShelter && canShelter) this.setState('shelter', t);
    else if (!wantShelter && this.state === 'shelter') this.setState('idle', t);
    const braced = this.state === 'shelter';

    // --- sustained extreme wind puts you down (same rule as a sail corner letting go).
    //     Bracing raises the bar; a ladder LOWERS it. Same exposure clock either way, so the storm
    //     speaks one language whether you're on your feet or up a rung. ---
    const knockAt = T.knockWind
      * (braced ? T.shelterKnockMult : 1)
      * (up ? T.ladderKnockMult : 1);
    if (ws > knockAt) this.exposure += dt;
    else this.exposure = Math.max(0, this.exposure - dt * T.knockBleed);
    if (this.exposure >= T.knockSustain) this.knockdown(t, wx, wz);

    // --- the climb itself: code-driven height, ClimbLadder plays on top (the knockdown precedent) ---
    if (this.state === 'climb') {
      const dy = this.climbTarget - this.climbY;
      const rung = T.climbRate * dt;
      if (Math.abs(dy) <= rung) {
        this.climbY = this.climbTarget;
        this.setState(this.climbY > 0.02 ? 'atTop' : 'idle', t);
      } else {
        this.climbY += Math.sign(dy) * rung;
      }
    }
    // Invariant: you cannot be standing on the grass while you are 2 m up a ladder. interact.js
    // releases a finished hold to 'idle' without knowing where you are; this puts you back in the
    // work stance instead of leaving you idling in mid-air.
    if (this.climbY > 0.02
        && (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
      this.setState('atTop', t);
    }

    // --- a gust below the knockdown bar can still break your stride ---
    if (!braced && this.gust > T.stumbleGust && this.stumbleCool <= 0
        && (this.state === 'idle' || this.state === 'walk' || this.state === 'run')) {
      this.stumbleCool = T.stumbleCooldown;
      this.setState('stumble', t);
      this.vel.x = this.vel.z = 0;
    }

    const st = STATES[this.state];
    const aloft = onLadder(this);   // re-read: the climb block above may have just landed you

    // --- movement ---
    const slow = 1 - Math.min(T.slowMax, ws / T.slowRef);   // prototype: rain + wind slow you
    let wantX = 0, wantZ = 0;
    if (!st.locked && !aloft) {
      const ix = input.x || 0, iz = input.z || 0;
      const mag = Math.hypot(ix, iz);
      if (mag > 1e-3) {
        // camera-relative: at camYaw 0 three.js looks down -Z, so forward = (-sin, -cos).
        const cy = input.camYaw || 0;
        const sin = Math.sin(cy), cos = Math.cos(cy);
        const nx = ix / mag, nz = iz / mag;
        const dx = nx * cos - nz * sin;
        const dz = -nx * sin - nz * cos;
        const target = (input.run ? T.runSpeed : T.walkSpeed) * Math.min(1, mag) * slow;
        wantX = dx * target; wantZ = dz * target;
        this.facing = turnToward(this.facing, Math.atan2(dx, dz), T.turnRate * dt);
      }
    }
    // approach the wanted velocity at a fixed accel (both directions — stopping is the same law)
    const dvx = wantX - this.vel.x, dvz = wantZ - this.vel.z;
    const dvm = Math.hypot(dvx, dvz);
    const step = T.accel * dt;
    if (dvm <= step || dvm < 1e-6) { this.vel.x = wantX; this.vel.z = wantZ; }
    else { this.vel.x += dvx / dvm * step; this.vel.z += dvz / dvm * step; }

    // --- gust shove: pressure ∝ speed², gust only, never while you're already on the ground ---
    const grounded = this.state === 'knocked' || this.state === 'getup';
    if (!grounded && this.gust > T.shoveGustMin && ws > 1e-3) {
      const a = T.shoveK * ws * ws * (braced ? T.shelterShoveMult : 1);
      this.shove.x += (wx / ws) * a * dt;
      this.shove.z += (wz / ws) * a * dt;
    }
    const bleed = Math.exp(-T.shoveDamp * dt);
    this.shove.x *= bleed; this.shove.z *= bleed;

    this.pos.x += (this.vel.x + this.shove.x) * dt;
    this.pos.z += (this.vel.z + this.shove.z) * dt;
    this.pos.y = this.groundAt(this.pos.x, this.pos.z);

    // Solids: push back out of anything we ended up inside. Pushout is perpendicular to the surface,
    // so walking into a wall at an angle keeps its tangential component and slides along it for free
    // — no separate slide pass. Velocity is deliberately NOT zeroed: the wind should still be able to
    // hold you against a fence, and the pushout wins over it every frame anyway.
    if (this.collide) {
      const r = this.collide(this.pos.x, this.pos.z, this.pos.y, this.pos.y + this.bodyHeight);
      if (r) { this.pos.x = r.x; this.pos.z = r.z; }
    }

    // --- body pitch: the sim owns it so a knockdown is deterministic and falls DOWNWIND,
    //     which a canned clip can't do. player.js just reads sim.pitch + sim.knockDir. ---
    const wantPitch = this.state === 'knocked' ? 1
      : this.state === 'getup' ? Math.max(0, 1 - this.stateT / (st.secs || 1))
      : 0;
    const pstep = dt / T.pitchSecs;
    this.pitch = clamp(this.pitch + clamp(wantPitch - this.pitch, -pstep, pstep), 0, 1);

    // --- wind lean: the sim owns it exactly like pitch, so the view stays a pure reader and the lean
    //     is deterministic (dt,t). Off windBase, not ws, so a gust punches the stumble/knock event
    //     while the posture holds. Suppressed where a lean would fight another root pose: on your back
    //     (pitch owns the body), braced (TakeCover is its own hunch), or up the ladder (you're pinned
    //     to the rungs, not standing in the wind). It eases to 0 there rather than snapping. ---
    const leanOK = this.pitch < 1e-3 && this.state !== 'shelter' && !up;
    const leanSpan = Math.max(1e-3, T.leanWindFull - T.leanWindMin);
    const wantLean = leanOK
      ? clamp((this.windBase - T.leanWindMin) / leanSpan, 0, 1)
      : 0;
    const lstep = dt / T.leanSecs;
    this.lean = clamp(this.lean + clamp(wantLean - this.lean, -lstep, lstep), 0, 1);
    // downwind is where the wind blows TOWARD; track it only while there's a wind to read, so a lull
    // leaves the last bearing rather than snapping leanDir to a default and twisting the body.
    if (ws > 0.1) { const inv = 1 / ws; this.leanDir.x = wx * inv; this.leanDir.z = wz * inv; }

    // --- locomotion state from actual speed (so shove/slow can't desync the feet).
    //     `aloft` is what holds you in atTop: it's unlocked (so hold-E works up there), and without
    //     this guard the speed check would immediately re-state you to idle and drop you off. ---
    if (!st.locked && !aloft) {
      const sp = this.speed;
      this.setState(sp < 0.15 ? 'idle' : sp > T.walkSpeed * 1.35 ? 'run' : 'walk', t);
    } else if (st.secs && this.stateT >= st.secs && st.next) {
      this.setState(st.next, t);
    }
    return this.state;
  }
}
