/**
 * interact.js — hold-E actions with radial progress.  (Lane D)
 *
 * contracts.js:  interact.register({id, pos, radius, holdSecs, label, canUse()->bool, onDone()})
 *
 * Zero imports (same reason as player.sim.js): the whole thing is fixed-dt and headless-testable.
 * `pos` is duck-typed {x,y,z}, so a THREE.Vector3 or a plain object both work.
 *
 * The busy handshake: this module both ENTERS and LEAVES the player's `busy` state. Nothing else
 * writes it, so a dropped release cannot strand the player — and if the world takes the player away
 * mid-hold (a gust puts them down), we notice `player.state` is no longer 'busy' and abort without
 * stomping on the state the world just set.
 */

export class Interact {
  constructor() {
    this.targets = new Map();
    this.active = null;      // the target currently being held
    this.progress = 0;       // 0..1 — the radial
    this.latched = false;    // a completed action re-arms only after E is released (see step)
    this.events = [];        // {type:'done'|'cancel', id, t} — drained by hud.js
  }

  /**
   * @param {object}   spec
   * @param {string}   spec.id
   * @param {object}   spec.pos        {x,y,z} — read live each step, so it may move (a sail corner does)
   * @param {number}   [spec.radius]   metres
   * @param {number}   [spec.holdSecs]
   * @param {string|function} [spec.label]  string, or (player)->string for live text
   * @param {function} [spec.canUse]   (player) -> bool.
   *   **Do not test `player.state` in here.** canUse is re-checked every frame to KEEP a hold alive,
   *   and starting a hold moves the player into `busy` — so `canUse: p => p.state === 'idle'` goes
   *   false on frame one and the action silently cancels its own hold. It looks exactly like a dead
   *   prompt. Gate on physical facts instead (carrying, position, height); locked states already
   *   can't start a hold, because step() checks `!player.busy` first. This bit twice: the ladder's
   *   climb and the fascia reach gate.
   * @param {function} [spec.onDone]   (player, t) -> void
   * @param {function} [spec.onHold]   (player, dt, t, progress) -> void, every frame the hold runs.
   *   For actions that do WORK over the hold rather than at the end of it — the broom's poke drains
   *   Lane B's pond per-frame (`drainPondAt(node, dt)` returns the kg shed that call), so the total
   *   is only knowable by accumulating it. onDone still fires at the end for the payoff.
   * @param {string}   [spec.clip]     verb played for the length of the hold ('Crank', 'PickUp', …).
   *   Must name a clip in player_anims.glb; omitted means the busy state's default Idle.
   * @returns {function} unregister
   */
  register(spec) {
    if (!spec || !spec.id) throw new Error('interact.register: id required');
    const target = {
      radius: 1.6, holdSecs: 1, label: '', canUse: null, onDone: null, onHold: null, clip: null,
      ...spec,
    };
    this.targets.set(target.id, target);
    return () => this.unregister(target.id);
  }

  unregister(id) {
    if (this.active && this.active.id === id) this.active = null, this.progress = 0;
    return this.targets.delete(id);
  }

  labelOf(target, player) {
    return typeof target.label === 'function' ? target.label(player) : target.label;
  }

  _usable(target, player) {
    return !target.canUse || !!target.canUse(player);
  }

  /** Nearest registered target in range whose canUse() passes. This is what a hold-E acts on. */
  nearest(player) {
    let best = null, bestD = Infinity;
    for (const target of this.targets.values()) {
      const p = typeof target.pos === 'function' ? target.pos() : target.pos;
      if (!p) continue;
      const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z);
      if (d <= target.radius && d < bestD && this._usable(target, player)) { best = target; bestD = d; }
    }
    return best;
  }

  /**
   * What the HUD should SHOW, which is not the same question as what E acts on.
   *
   * A usable action always wins. But when nothing is usable, this returns the nearest action that
   * is merely unavailable, so the prompt can say WHY instead of vanishing. That distinction came
   * out of playing it: walking to the shed table with the ladder in your hands made the prompt
   * disappear, which reads as a broken game rather than a full pair of hands — and every target
   * already had a perfectly good sentence sitting in its `label`, unreachable, because canUse had
   * filtered it out before the label was ever asked.
   *
   * @returns {{target, usable, label}|null}
   */
  visible(player) {
    const usable = this.nearest(player);
    if (usable) return { target: usable, usable: true, label: this.labelOf(usable, player) };
    let best = null, bestD = Infinity;
    for (const target of this.targets.values()) {
      const p = typeof target.pos === 'function' ? target.pos() : target.pos;
      if (!p) continue;
      const d = Math.hypot(p.x - player.pos.x, p.z - player.pos.z);
      if (d <= target.radius && d < bestD) { best = target; bestD = d; }
    }
    return best ? { target: best, usable: false, label: this.labelOf(best, player) } : null;
  }

  cancel(t, player) {
    if (!this.active) return;
    // only hand the player back if they're still ours — a knockdown mid-hold already re-stated them
    if (player.state === 'busy') player.setState('idle', t);
    player.busyClip = null;
    this.events.push({ type: 'cancel', id: this.active.id, t });
    this.active = null;
    this.progress = 0;
  }

  /**
   * @param {number} dt @param {number} t
   * @param {PlayerSim} player
   * @param {boolean} holding  is E held this frame
   * @returns {{target, progress, label, holding, usable}} for hud.js to draw the prompt + radial.
   *   `usable:false` means the prompt is a REASON, not an offer — grey it out and don't show a
   *   radial. Lane A: this is the greyed-prompt surface I offered; `label` is already the sentence
   *   ("hands full", "out of reach — needs the ladder", "you need the broom").
   */
  step(dt, t, player, holding) {
    // One press, one action: a completed hold latches until E is released. Without this, a held key
    // re-arms the instant the action finishes and the same action fires every holdSecs forever
    // (leaning on the shed table would deal you a spare a second, indefinitely).
    if (!holding) this.latched = false;

    const near = this.nearest(player);

    if (this.active) {
      const stolen = player.state !== 'busy';        // something else claimed the player
      if (!holding || near !== this.active || !this._usable(this.active, player) || stolen) {
        this.cancel(t, player);
      }
    }

    if (!this.active && holding && !this.latched && near && !player.busy) {
      this.active = near;
      this.progress = 0;
      player.busyClip = near.clip || null;   // the verb: Crank at a turnbuckle, PickUp at the table
      player.setState('busy', t);
    }

    if (this.active) {
      this.progress += dt / Math.max(1e-6, this.active.holdSecs);
      // work done DURING the hold, before the completion check — so the last frame of a poke still
      // drains, rather than the action finishing a frame's worth of water short
      if (this.active.onHold) this.active.onHold(player, dt, t, Math.min(1, this.progress));
      if (this.progress >= 1) {
        const done = this.active;
        this.active = null;
        this.progress = 0;
        this.latched = true;
        player.setState('idle', t);      // release busy FIRST — onDone may pickUp(), which refuses while busy
        player.busyClip = null;          // ...and after it, so the carry clips win on the next frame
        if (done.onDone) done.onDone(player, t);
        this.events.push({ type: 'done', id: done.id, t });
      }
    }

    if (this.active) {
      return {
        target: this.active,
        progress: this.progress,
        label: this.labelOf(this.active, player),
        holding: true,
        usable: true,
      };
    }
    const shown = this.visible(player);
    return {
      target: shown ? shown.target : null,
      progress: 0,
      label: shown ? shown.label : '',
      holding: false,
      usable: !!(shown && shown.usable),
    };
  }
}

/**
 * Register the standard yard actions (PLAN3D §5-D.4). Duck-typed against the contracts so Lane D
 * never edits Lane B's or Lane A's files — anything not yet landed is simply skipped.
 *
 * Every closure reads `sailRig.corners[i]` LIVE rather than capturing the corner object. Lane A's
 * THREADS note: `attach()` REPLACES the corners array, so a captured corner is a stale object the
 * sim no longer steps — the prompt would gate forever on a `broken` flag that can never change
 * again. Reading by index means a re-rig can't strand these targets whether or not we get re-wired.
 *
 * @param {Interact} interact
 * @param {object} deps  {sailRig, world}
 *   sailRig.corners      -> [{anchorId, hw, load, broken}]   (contracts.js, Lane B)
 *   sailRig.repair(i)    -> void        (decision 4)
 *   sailRig.trim(i,d)    -> void        (decision 4)
 *   sailRig.cornerPos(i) -> Vector3     (decision 4 — live world position; a flogging corner moves)
 *   world.shedTable      -> {pos}       (Lane A — until it lands, the pickup self-skips)
 */
export function wireYardActions(interact, deps = {}) {
  const { sailRig, world } = deps;
  // createLadder publishes itself onto the Interact instance, so main.js doesn't have to thread a
  // ladder through to get the fascia reach gate. An explicit dep still wins (tests pass one).
  const ladder = deps.ladder || interact.ladder || null;
  // Publish the CURRENT rig for lane-D systems built before it exists (the broom needs ponds +
  // drainPondAt). main.js re-calls wireYardActions through rigSail() every time attach() replaces
  // the rig object, so reading `interact.sailRig` is always the live one — which is the same reason
  // the corner closures below read by index rather than capturing.
  interact.sailRig = sailRig || null;
  const wired = [];
  const cornerAt = (i) => (sailRig && sailRig.corners && sailRig.corners[i]) || null;
  const anchorOf = (i) => {
    const c = cornerAt(i);
    if (!c) return null;
    return c.anchor || (world && world.anchors && world.anchors.find((a) => a.id === c.anchorId)) || null;
  };
  // a flogging corner is MOVING — resolve position every frame, never once at wire time
  const posAt = (i) => () => {
    const c = cornerAt(i);
    if (!c) return null;
    // A fascia corner is worked AT THE BRACKET, not at the cloth: the corner has detached and the
    // sail is hanging down somewhere, but re-attaching it means getting a shackle onto a fitting
    // 2.48 m up a wall. So the prompt lives at the anchor and you need the ladder to hold it.
    if (ladder && ladder.needsLadder(anchorOf(i))) {
      const a = anchorOf(i);
      return { x: a.pos.x, y: a.pos.y, z: a.pos.z + 0.9 };
    }
    return (sailRig.cornerPos && sailRig.cornerPos(i)) || c.pos || null;
  };
  /** Fascia work needs you up the ladder that's planted under THAT bracket. */
  const canReach = (i, p) => {
    const a = anchorOf(i);
    if (!ladder || !ladder.needsLadder(a)) return true;      // everything else is ground work
    return ladder.isWorking(a.id) && p.reachY >= a.pos.y;
  };

  if (sailRig && Array.isArray(sailRig.corners)) {
    sailRig.corners.forEach((_corner, i) => {
      // re-rig a broken corner — costs the spare you're carrying
      wired.push(interact.register({
        id: `rerig_${i}`,
        pos: posAt(i),
        radius: 1.8,
        holdSecs: 2.5,
        label: (p) => (canReach(i, p) ? 're-rig corner' : 'out of reach — needs the ladder'),
        clip: 'Crank',
        canUse: (p) => !!(cornerAt(i) && cornerAt(i).broken)
          && p.carrying === 'spare' && !!sailRig.repair && canReach(i, p),
        onDone: (p) => { p.carrying = null; sailRig.repair(i); },
      }));
      // per-corner turnbuckle trim — new vs the prototype; makes corners individual
      wired.push(interact.register({
        id: `trim_${i}`,
        pos: posAt(i),
        radius: 1.8,
        holdSecs: 1.2,
        label: (p) => (canReach(i, p) ? 'tighten turnbuckle' : 'out of reach — needs the ladder'),
        clip: 'Crank',
        canUse: (p) => !!cornerAt(i) && !cornerAt(i).broken && !!sailRig.trim && canReach(i, p),
        onDone: () => sailRig.trim(i, +0.1),
      }));
    });
  }

  if (world && world.shedTable) {
    wired.push(interact.register({
      id: 'spare_table',
      pos: world.shedTable.pos,
      radius: 1.5,
      holdSecs: 0.6,
      label: (p) => (p.carrying ? 'hands full' : 'take a spare'),
      clip: 'PickUp',
      canUse: (p) => !p.carrying,           // hands-full rule
      onDone: (p, t) => p.pickUp('spare', t),
    }));
  }

  return () => wired.forEach((un) => un());
}
