/**
 * ladder.js — the ladder sub-system.  (Lane D, SPRINT4 decision 12)
 *
 * Why it exists: the house fascia brackets sit at y=2.48 and a 1.72 m person's hands reach 2.20.
 * Two hundred millimetres is the whole mechanic. Everything else in the yard you can rig from the
 * ground — a sail post is tensioned from a cleat at its base, a tree anchor is a strop you throw —
 * but a bracket bolted 2.5 m up a bare wall is not negotiable, and E's ladder tops out at exactly
 * 2.9 m. The asset and the yard were built for each other; this file is the verb between them.
 *
 * The loop it creates is the "limited hands" rule from DESIGN.md doing real work:
 *   ladder and spare are BOTH carry items, and you can only hold one.
 *   So a fascia repair costs two trips — fetch the ladder, plant it, go back for the spare —
 *   while a post repair costs one. The house is the expensive anchor to depend on, which is
 *   exactly what E's ratingHint 0.35 / collateral "gutter" is already telling you in the data.
 *
 * Ownership: Lane D. Self-wires from createPlayer() in player.js, so main.js (Lane A's file) needs
 * no change to get this — it already hands createPlayer the scene, world and interact.
 */
import * as THREE from '../vendor/three.module.js';

// GLTFLoader is imported lazily, not at module scope, and the reason is the suite: the vendored
// addons import the bare specifier 'three', which only resolves under index.html's importmap, so a
// top-level import here would drag the whole GL chain in and make this file unloadable from
// d.test.js (node resolves relative paths only — three.module.js is fine, its addons are not).
// That mattered: needsLadder below is a RULE, and the suite was testing a hand-copied duplicate of
// it rather than the rule itself. Same reasoning as broom.js.
export const LADDER_URL = './models/ladder_01_v1.glb';

/**
 * Which anchors you cannot rig from the ground.
 *
 * NOT a height test, deliberately. A pure "is it above reach?" rule would rope in the posts (3.95 m)
 * and tree limbs (up to 5.05 m) and turn every repair into a two-trip ladder job — untrue to how
 * sails are rigged, and it would silently invalidate the recorded §7 run and Lane B's gate asserts.
 * The real distinction isn't height, it's WHERE THE WORK HAPPENS: a blown fascia corner is re-made
 * at the bracket on the wall, while a blown post or tree corner is re-made on the cloth, which has
 * fallen to somewhere you can stand. Height is a coincidence of that; the bracket is the reason.
 *
 * SPRINT10 §Gate 1 — keyed on the MECHANISM, from site data. `type === 'house'` was site_01's
 * spelling of the idea, not the idea itself, and contracts.js types anchors as a closed enum
 * `'house'|'tree'|'post'` that site_02's carport does not fit. That mattered because it fails OPEN:
 * when `needsLadder` says false, `canReach` in interact.js returns true UNCONDITIONALLY, so the
 * player re-rigs a bracket well over their head while standing on the grass and the whole mechanic
 * quietly stops existing — no error, no red. Same disease as StumbleBack's dead threshold and the
 * camera-less shadow grid: a rule keyed on the wrong thing, failing silently open.
 *
 * Measured against E's shipped `carport_01_v1.glb`, which is exactly the case the audit predicted —
 * and note it carries BOTH mechanisms, so a per-STRUCTURE rule would still be wrong:
 *     beam_anchor_01/02   y=2.36   anchor_type "carport"        → over the 2.20 m reach: BRACKET
 *     post_anchor_01/02   y=1.75   anchor_type "carport_post"   → under it, reachable: CLOTH
 * Neither is typed 'house'. The beam is the double trap — E's worst rating in the game (0.22) AND
 * the one you need the ladder for.
 *
 * Reads DATA first, exactly like `anchor.ratingHint` already does from the GLB's `userData`:
 *   · `work: 'bracket' | 'cloth'`  — SPRINT10's spelling, and the canonical one. Positive about the
 *     mechanism rather than a negation, which is why it beats the boolean I proposed in Sprint 9.
 *   · `worksAtBracket: boolean`    — my Sprint-9 spelling, still honoured so nothing that already
 *     sets it breaks.
 *   · else `type === 'house'`      — site_01's shape. Nothing there carries either field, so this is
 *     a no-op on backyard_01 and E's anchor tripwires stay green.
 * Lane A: emit `work` per anchor in the site JSON and the ladder follows the site with no code
 * change. The carport mapping you want is beam→bracket, post→cloth, per the heights above.
 */
export const needsLadder = (anchor) => {
  if (!anchor) return false;
  if (anchor.work === 'bracket') return true;                                     // SPRINT10 spelling
  if (anchor.work === 'cloth') return false;
  if (typeof anchor.worksAtBracket === 'boolean') return anchor.worksAtBracket;   // Sprint-9 spelling
  return anchor.type === 'house';                                                 // site_01's shape
};

/**
 * What to call the thing the ladder serves, in the player's own words.
 *
 * SPRINT13 pool (the integrator's QA pass): the place label hardcoded "the fascia" — true on
 * backyard_01, a lie on the corner block, where the bracket is a carport beam. Keyed on `type`,
 * which has been a CHECKED enum since Sprint 11 (`validateSite` rejects an unknown one), so a new
 * site can only arrive here with a type somebody typed on purpose.
 *
 * The fallback names the MECHANISM, not a guessed structure: an unmapped bracket reads "the bracket
 * needs the ladder", which is vague but never false. Same instinct as `needsLadder` itself — the
 * thing that broke here was a rule keyed on site_01's spelling of the idea instead of the idea.
 */
const BRACKET_NOUN = { house: 'the fascia', carport: 'the carport bracket' };
const bracketNoun = (a) => BRACKET_NOUN[a?.type] ?? 'the bracket';

/** Where the player stands to work a bracket anchor: out from the wall, at the anchor's x. */
const STAND_OFF = 0.9;      // m clear of the wall face
const PLACE_RANGE = 2.6;    // m — how close you must be to a fascia anchor to plant the ladder
const RUNG_CLEAR = 0.55;    // m — feet this far below the top rung, so the fascia is at chest height

/**
 * @param {THREE.Object3D} scene
 * @param {object} world     contracts World (must be dressed — anchors are final only after dress())
 * @param {object} interact  Lane D's Interact
 * @param {object} player    the PlayerSim
 * @returns {object} the ladder system
 */
export function createLadder(scene, world, interact, player) {
  const anchors = (world.anchors || []).filter(needsLadder);
  if (!anchors.length) {
    // no fascia in this yard (a bare harness, say) — the whole sub-system is moot, don't half-wire it
    return { placedAt: null, carried: false, needsLadder, isWorking: () => false,
      workY: () => 0, servedAnchor: () => null, update() {}, dispose() {} };
  }

  const state = {
    carried: false,          // in the player's hands
    placedAt: null,          // anchor id, or null while stowed/carried
    base: new THREE.Vector3(),
    topY: 2.9,               // overwritten from the GLB's ladder_top node
    view: null,
    dropped: false,          // lying in the grass where it was lost (see the reconcile in update)
    dropYaw: 0,
  };

  // Home: leaning on the shed, near the spare table but NOT on top of it. Read from world.shedTable
  // so it follows the shed if Lane A moves it. The offset is deliberately ~3 m: at 1.4 m the two
  // prompts overlapped and standing at the ladder offered you "take a spare", which is the kind of
  // thing that reads as a broken game rather than a crowded shed.
  const home = new THREE.Vector3(10.4, 0, 3.4);
  if (world.shedTable && world.shedTable.pos) {
    home.set(world.shedTable.pos.x + 1.4, 0, world.shedTable.pos.z - 2.6);
  }
  home.y = world.heightAt ? world.heightAt(home.x, home.z) : 0;
  state.base.copy(home);

  // --- view -----------------------------------------------------------------
  // Dynamic import: only ever runs in a browser (see the note at the top of the file).
  if (scene) {
    import('../vendor/addons/loaders/GLTFLoader.js').then(({ GLTFLoader }) => {
      new GLTFLoader().load(LADDER_URL, (g) => {
        const obj = g.scene;
        const top = obj.getObjectByName('ladder_top');
        if (top) state.topY = top.position.y;
        obj.traverse((o) => { if (o.isMesh) { o.castShadow = true; o.frustumCulled = false; } });
        state.view = obj;
        scene.add(obj);
        syncView();
      }, undefined, () => { /* missing asset: the mechanic still works, you just can't see it */ });
    }).catch(() => { /* headless (selftest/node): the rules run, there is just nothing to draw */ });
  }

  const LEAN = 0.26;   // rad (~15°) — a ladder stood bolt upright reads as a post, not a ladder
  function syncView() {
    if (!state.view) return;
    state.view.visible = !state.carried;
    state.view.position.copy(state.base);
    const a = state.placedAt && world.anchors.find((x) => x.id === state.placedAt);
    if (a) {
      // planted: yaw so local +Z faces the wall, then tip the head into it. +X rotation carries the
      // top toward local +Z, which is the wall — so the feet stand off and the head rests on it.
      state.view.rotation.set(LEAN, Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z), 0);
    } else if (state.dropped) {
      // Dropped, not stowed: it lies FLAT where it fell. The stowed pose is a slouch against the
      // shed wall, and a slouch in open grass reads as a ladder leaning on thin air — which looks
      // like a bug at exactly the moment the player is hunting for the thing.
      state.view.rotation.set(Math.PI / 2, state.dropYaw, 0);
    } else {
      state.view.rotation.set(0, 0.6, 0.22);   // stowed: slouched against the shed
    }
  }

  /** The fascia anchor this ladder is currently serving, if any. */
  const servedAnchor = () => (state.placedAt ? world.anchors.find((a) => a.id === state.placedAt) : null);

  /** Standing height at the top of the ladder — feet a rung or two down from the very top. */
  const workY = () => Math.max(0, state.topY - RUNG_CLEAR);

  /**
   * True if the player is up THIS ladder and can work the given anchor.
   * Height only, deliberately — hold-E moves the player into `busy`, so testing for state 'atTop'
   * here would make a fascia repair un-usable the moment it started and cancel its own hold.
   */
  function isWorking(anchorId) {
    return state.placedAt === anchorId && player.climbY > workY() - 0.15;
  }

  // --- interactions ---------------------------------------------------------
  const wired = [];

  // 1. pick the ladder up (from its home, or from wherever it's planted)
  wired.push(interact.register({
    id: 'ladder_take',
    pos: () => (state.carried ? null : state.base),
    radius: 1.6,
    holdSecs: 0.8,
    clip: 'PickUp',
    label: (p) => (p.carrying ? 'hands full' : state.placedAt ? 'take the ladder back' : 'take the ladder'),
    canUse: (p) => !state.carried && !p.carrying && p.climbY < 0.02,
    onDone: (p, t) => {
      state.carried = true;
      state.placedAt = null;
      state.dropped = false;
      p.pickUp('ladder', t);
      syncView();
    },
  }));

  // 2. plant it at a fascia anchor
  for (const a of anchors) {
    wired.push(interact.register({
      id: `ladder_place_${a.id}`,
      // stand off the wall, on the yard side — the ladder leans in toward the bracket
      pos: () => ({ x: a.pos.x, y: 0, z: a.pos.z + STAND_OFF }),
      radius: PLACE_RANGE,
      holdSecs: 1.0,
      clip: 'PickUp',
      // Reads as an offer when you can, and as a reason when you can't — interact.visible() now
      // shows unusable targets greyed, and a label written only as an offer explains nothing there.
      // Standing under a blown bracket holding a spare, "the fascia needs the ladder" is the single
      // most useful sentence in the game — so it has to name the RIGHT structure, or it's the single
      // most confusing one. It said "fascia" in the corner block until SPRINT13.
      // ...and it must not lie about WHERE. "it's by the shed" is true until the wind takes it out
      // of your hands, and then it's the game sending you to the wrong end of the yard mid-storm.
      label: (p) => (p.carrying === 'ladder'
        ? `set the ladder under ${a.id.toUpperCase()}`
        : `${bracketNoun(a)} needs the ladder — ${state.dropped ? 'you dropped it out there' : "it's by the shed"}`),
      canUse: (p) => p.carrying === 'ladder',
      onDone: (p, t) => {
        state.carried = false;
        state.placedAt = a.id;
        state.dropped = false;
        state.base.set(a.pos.x, world.heightAt ? world.heightAt(a.pos.x, a.pos.z + STAND_OFF) : 0,
          a.pos.z + STAND_OFF);
        p.carrying = null;
        p.events.push({ type: 'ladderPlaced', anchorId: a.id, t });
        syncView();
      },
    }));
  }

  // 3. climb it — only when it's planted, and only from the ground
  wired.push(interact.register({
    id: 'ladder_climb',
    pos: () => (state.placedAt && !state.carried ? state.base : null),
    radius: 1.5,
    holdSecs: 0.4,
    clip: 'PickUp',
    label: 'climb',
    // NB: no test on p.state here. Starting a hold moves the player into `busy`, so a canUse that
    // reads state goes false the instant the hold begins and cancels itself. climbY is the honest
    // gate (are you on the ground?), and locked states can't start a hold anyway.
    canUse: (p) => !!state.placedAt && !state.carried && p.climbY < 0.02,
    onDone: (p, t) => {
      p.pos.x = state.base.x; p.pos.z = state.base.z;       // step onto the rungs
      const a = servedAnchor();
      if (a) p.facing = Math.atan2(a.pos.x - state.base.x, a.pos.z - state.base.z);
      p.climbTo(workY(), t);
    },
  }));

  const api = {
    get placedAt() { return state.placedAt; },
    get carried() { return state.carried; },
    get base() { return state.base; },
    get topY() { return state.topY; },
    workY,
    isWorking,
    servedAnchor,
    needsLadder,

    /**
     * Drive descent from input. Held S climbs down; nothing else can strand you up there, and a
     * knockdown already drops climbY to 0 on its own.
     * player.js calls this each frame from the same input it reads for movement.
     */
    update(dt, t, input) {
      // SPRINT13 pool — "the dropped spare's whereabouts are unknown" is the small half of this.
      // The player can lose the ladder without telling us: knockdown() calls drop() (player.sim.js),
      // which nulls `carrying` and pushes an event nothing here listens for. state.carried stayed
      // true forever, and every consequence of that is silent:
      //   · syncView hides the mesh (`visible = !carried`)      → the ladder is invisible
      //   · ladder_take.pos() returns null while carried        → nothing to walk to
      //   · ladder_take.canUse needs !state.carried             → it can never be picked up again
      // Blown over mid-carry and the ladder left the game, while ladder_place went on saying "it's
      // by the shed". It wasn't. On the corner block that silently ends the site's whole thesis:
      // cb1/cb2 are bracket work, and bracket work needs a ladder that no longer exists.
      // The player's hands are the one source of truth, so reconcile against them every frame and
      // let the ladder FALL where it was lost — the same instinct as STATES.shelter, which enters
      // and leaves itself so a dropped release can't strand you. Fetching it is now a cost you can
      // see and walk to, which is what the mechanic wanted from the knockdown in the first place.
      if (state.carried && player.carrying !== 'ladder') {
        state.carried = false;
        state.placedAt = null;
        state.dropped = true;
        state.dropYaw = player.facing ?? 0;
        state.base.set(player.pos.x,
          world.heightAt ? world.heightAt(player.pos.x, player.pos.z) : 0,
          player.pos.z);
      }
      if (player.state === 'atTop' && input && input.z < 0) player.climbTo(0, t);
      syncView();
    },

    dispose() {
      wired.forEach((un) => un());
      if (interact.ladder === api) interact.ladder = null;
      if (state.view) scene.remove(state.view);
    },
  };

  // Publish onto the Interact instance so wireYardActions can find the reach gate without main.js
  // (Lane A's file) having to learn about ladders and thread it through. Interact is Lane D's own
  // class, so this stays inside the lane; an explicit `deps.ladder` still wins if anyone passes one.
  interact.ladder = api;
  return api;
}
