/**
 * SHADES — the yard. Lane A owns this file.
 *
 * M0 is deliberately graybox: every mesh here is a stand-in with the right
 * dimensions and the right NAME, so Lane E's real GLBs drop into the same slots
 * without anyone re-deriving positions. What is NOT placeholder, and what other
 * lanes should build against, is the layout: anchor positions, ground heights,
 * the garden rect and the sun direction.
 *
 * Geometry conventions: meters, +Y up, origin at yard centre on the ground.
 * North (-Z) is the house edge. The yard is 30 (x) by 20 (z).
 */

import * as THREE from '../vendor/three.module.js';
import { ANCHOR_TYPE, createStubWind } from './contracts.js';
// SPRINT15 gate 1.2 — E's patch, applied verbatim by the integrator at merge
// (the leadFor precedent): a node-less anchor takes the factory's word at
// construction, because dress() will never reach it. Object.assign ignores
// null, so each hunk is inert exactly where adoptAnchor already owns the job.
// At the ruled 1.00 this changes ZERO shipped numbers; it exists for the NEXT
// asset. E's e.test pins runtime === manifest so divergence can't be silent.
import { factoryExtras } from './ratings.js';
import { validateSiteWind } from './weather.core.js';

/**
 * Full strength: this anchor takes whatever its hardware is rated for.
 *
 * SPRINT11 — Lane D's landmine, defused before anyone can step on it. Every
 * anchor now carries a NUMBER here, always. It used to be set only by
 * `adoptAnchor`, off Lane E's baked `rating_hint` — so GLB-backed anchors had it
 * and the ones built straight from site JSON (site_02's honest posts q1..q4) did
 * not. Their `ratingHint` was `undefined`.
 *
 * That matters the moment anyone wires the ratings into the failure threshold,
 * which is the open question D put to me. The naive wire is
 * `load > hw.rating * anchor.ratingHint`, and with `undefined` that is
 * `load > NaN` — **always false**. The honest posts wouldn't read weak, they'd
 * be UNBREAKABLE, and the carport would become the only anchor in the yard that
 * can fail. The trap would appear to work, for entirely the wrong reason, and it
 * would have looked like a successful playtest.
 *
 * "Default before you multiply" — D's words. This is the default, at the source,
 * so no consumer has to remember. 1 = honest steel; E's traps ship lower.
 */
const DEFAULT_RATING_HINT = 1;

/** Degrees → radians. Site JSON is authored in degrees; nobody writes π/2 by hand. */
const rad = (deg) => ((deg ?? 0) * Math.PI) / 180;

/**
 * Ground height in meters at a world XZ. Pure and cheap — this is called by the
 * player every frame and by every piece of debris, so it stays closed-form
 * rather than sampling a texture. Gentle by design (about ±0.3 m): enough that
 * water has somewhere to go and the yard doesn't read as a table, not so much
 * that it fights the rigging puzzle.
 *
 * @param {number} x
 * @param {number} z
 * @returns {number}
 */
export function heightAt(x, z) {
  return (
    0.18 * Math.sin(x * 0.21 + 1.3) * Math.cos(z * 0.27 - 0.4) +
    0.09 * Math.sin(x * 0.53 - 2.1) * Math.sin(z * 0.41 + 0.8) -
    0.06 * Math.cos(x * 0.11)
  );
}

const SITE_DIR = new URL('../data/sites', import.meta.url).href;

/**
 * Load a site. SPRINT10 gate 1 — the yard stopped being code today.
 *
 * Everything that used to be a constant in this file (bed rect, house line,
 * tree and post placements, shed, gnome, sun angle) is now `data/sites/*.json`,
 * and world.js is a builder. The proof of a faithful extraction is that nothing
 * moved: Lane E's anchor tripwires pin five branch positions to 6 dp and
 * a.test's quad-band asserts pin the whole rigging geometry, so a byte-identical
 * backyard_01 is checkable rather than claimable.
 *
 * Async and separate from createWorld() for the same reason dress() is: the
 * selftest builds yards with no server, and a fetch in the constructor is either
 * a break or a flake. Callers load the site, then build.
 *
 * @param {string} name  e.g. 'backyard_01'
 */
export async function loadSite(name, dir = SITE_DIR) {
  const url = `${dir}/${name}.json`;
  const res = await fetch(url);
  if (!res.ok) throw new Error(`world: cannot load site ${url} (${res.status})`);
  return validateSite(await res.json(), name);
}

/**
 * Sites are content, so they fail loud rather than half-build. Every field this
 * checks is one that would otherwise produce a yard that looks fine and is
 * subtly wrong — an absent bed silently becomes NaN coverage, a missing anchor
 * list silently becomes an unriggable site.
 */
export function validateSite(site, name = site?.id ?? '?') {
  const bad = [];
  if (!site || typeof site !== 'object') bad.push('not an object');
  else {
    if (!site.yard?.width || !site.yard?.depth) bad.push('yard.width/depth');
    if (!site.gardenBed) bad.push('gardenBed');
    if (!Number.isFinite(site.sun?.elevationDeg)) bad.push('sun.elevationDeg');
    if (!Number.isFinite(site.sun?.azimuthDeg)) bad.push('sun.azimuthDeg');
    const ids = [
      ...(site.house?.anchors ?? []).map((a) => a.id),
      ...(site.trees ?? []).flatMap((t) => (t.anchors ?? []).map((a) => a.id)),
      ...(site.posts ?? []).map((p) => p.id),
      ...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => a.id)),
    ];
    if (!ids.length) bad.push('no anchors at all — nothing to rig to');
    const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
    if (dupes.length) bad.push(`duplicate anchor ids: ${[...new Set(dupes)].join(', ')}`);
    // Lane D's field. `work` is the MECHANISM, not the anchor's type: a post is
    // tensioned from a cleat at its base and a tree strop is thrown, but a
    // bracket bolted up a bare wall is the one you cannot fake — so that, and
    // only that, wants the ladder. Keyed on mechanism because `type` is a closed
    // enum ('house'|'tree'|'post') that site_02's carport doesn't fit, and
    // needsLadder keyed on type FAILS OPEN there: the ladder mechanic silently
    // vanishes and you re-rig a 2.6 m bracket standing on the grass (D's audit).
    const works = [
      ...(site.house?.anchors ?? []),
      ...(site.trees ?? []).flatMap((t) => t.anchors ?? []),
      ...(site.posts ?? []),
      ...(site.structures ?? []).flatMap((s) => s.anchors ?? []),
    ];
    for (const a of works) {
      if (a.work !== 'cloth' && a.work !== 'bracket') {
        bad.push(`anchor ${a.id}: work must be "cloth" or "bracket", got ${JSON.stringify(a.work)}`);
      }
      // SPRINT11 — the enum is CHECKED now, not just documented. It was a JSDoc
      // comment before, which is why site_02 could type a carport 'post' and
      // nothing said a word (D's flag). An unenforced enum is decoration; this
      // one fails loud, so the next site that doesn't fit argues with the list
      // in contracts.js instead of quietly joining the wrong family.
      if (!ANCHOR_TYPE.includes(a.type)) {
        bad.push(`anchor ${a.id}: type ${JSON.stringify(a.type)} is not one of ${ANCHOR_TYPE.join('|')}`);
      }
    }
    // C's validator, wired at site load as they asked in SPRINT10: a site's wind
    // block is hand-authored data, so it fails loud like a storm's rather than
    // being silently clamped. A site with no wind block passes (backyard_01 has
    // no funnel) — that's C's contract, not an accident.
    bad.push(...validateSiteWind(site.wind, name).errors);
  }
  if (bad.length) throw new Error(`world: site '${name}' is invalid:\n  ${bad.join('\n  ')}`);
  return site;
}

/** Direction from the GROUND toward the SUN (see contracts.js), off site data. */
function sunDirOf(site) {
  const el = (site.sun.elevationDeg * Math.PI) / 180;
  const az = (site.sun.azimuthDeg * Math.PI) / 180;
  return new THREE.Vector3(
    Math.cos(el) * Math.sin(az),
    Math.sin(el),
    Math.cos(el) * Math.cos(az),
  ).normalize();
}

const COLORS = {
  sky: 0x9fc4dd,
  grass: 0x4a7c3f,
  soil: 0x6b4a2f,
  plant: 0x7fce6a,
  house: 0x8a8f96,
  trim: 0x6e737a,
  bark: 0x5a3d24,
  leaf: 0x2f6b28,
  steel: 0x9aa4ad,
  timber: 0x7a6a4f,
};
// (kept explicit rather than clever — Lane E will replace these with materials
// baked into the GLBs, at which point this table shrinks to nothing.)

/**
 * Build the yard.
 *
 * @param {THREE.Scene} scene
 * @param {object} [opts]
 * @param {import('./contracts.js').Wind} [opts.wind]
 *   Wind is injected because tree anchors sway with it, and sway is dynamic
 *   load — the whole reason a tree anchor is scarier than a post. Defaults to
 *   the calm stub so world.js is usable headless before Lane C lands.
 * @returns {import('./contracts.js').World}
 */
export function createWorld(scene, opts = {}) {
  const wind = opts.wind ?? createStubWind({ calm: true });

  // SPRINT10 gate 1. Every one of these was a module constant in this file an
  // hour ago; they're site data now. They keep their old local names on purpose
  // — the builder below is ~500 lines of geometry that was already correct, and
  // rebinding the names is a change of SOURCE, not of behaviour. That's what
  // makes "backyard_01 is byte-identical" a structural claim rather than a hope.
  const site = validateSite(
    opts.site ?? (() => { throw new Error(
      'world: createWorld now needs a site — `createWorld(scene, { wind, site: await loadSite("backyard_01") })`. '
      + 'The yard stopped being code in SPRINT10; data/sites/*.json is the source of truth.'); })(),
  );
  const YARD = site.yard;
  const GARDEN_BED = site.gardenBed;
  const SUN_DIR = sunDirOf(site);
  const HOUSE = site.house;
  // SPRINT14 — `rotY` was MISSING here, and it made the shed invisible.
  //
  // Every other rotatable prop gets its degrees converted on the way in
  // (SHED_TABLE, GNOME, BIKE, all one line below); the shed alone was bound
  // raw, so `SHED.rotY` was `undefined`, `shed.rotation.y = undefined` gave the
  // object an all-NaN quaternion, and three.js quietly declines to draw a mesh
  // whose world matrix is NaN. No throw, no warning, nothing on the console —
  // the Hendersons' shed has simply never been in the yard, through every
  // sprint and onto the public URL, while `ladder.js` has been parking the
  // ladder "leaning on the shed" against thin air and the camera has been
  // colliding with it (it is in `solids`).
  //
  // Found by opening backyard_01 in the yard editor and noticing an object in
  // the scene graph that was not on the glass. Proven by assigning a real
  // radian value at runtime and watching the shed appear. There is now an
  // a.test assert on the composed matrix, because "nothing is NaN" is exactly
  // the kind of claim that has to be able to fail.
  const SHED = site.shed ? { ...site.shed, rotY: rad(site.shed.rotYDeg) } : null;
  const SHED_TABLE = site.shedTable ? { ...site.shedTable, rotY: rad(site.shedTable.rotYDeg) } : null;
  const GNOME = site.gnome ? { ...site.gnome, rotY: rad(site.gnome.rotYDeg) } : null;
  // SPRINT12: the per-client prop, the gnome pattern — a named top-level key,
  // not a generic props[]. See the site JSON's _why for the lean/fence math.
  const BIKE = site.bike ? { ...site.bike, rotY: rad(site.bike.rotYDeg) } : null;
  const treeSpecs = site.trees ?? [];
  const postSpecs = site.posts ?? [];

  const root = new THREE.Group();
  root.name = 'yard';
  scene.add(root);

  /** @type {THREE.Object3D[]} */
  const solids = [];
  /** @type {import('./contracts.js').Anchor[]} */
  const anchors = [];
  /** @type {{group: THREE.Object3D, phase: number, base: THREE.Euler}[]} */
  const canopies = [];
  /** Graybox stand-ins, kept so dress() can retire them once E's GLBs load. */
  const graybox = { house: null, trees: new Map(), bed: null };
  /**
   * E's three wilt states, siblings in one GLB — toggled by visibility rather
   * than reloaded, which is why they all ship together.
   * @type {{full: THREE.Object3D, tattered: THREE.Object3D, dead: THREE.Object3D}|null}
   */
  let plants = null;

  // --- sky & light -------------------------------------------------------
  // Calm-day only. Lane C's skyfx.js takes over the sky and this becomes the
  // "before" state the storm darkens away from.
  scene.background = new THREE.Color(COLORS.sky);
  scene.fog = new THREE.Fog(COLORS.sky, 34, 95);

  // Sky fill carries more here than it would in most scenes. The sun sits in
  // the NORTH (this is an Australian yard — "southerly change", gum trees), and
  // the house is the yard's north edge, so the wall the player spends the whole
  // game looking at is permanently backlit. That's correct, and it's also how
  // a real south-facing rear wall looks — but the fascia line carries three of
  // the seven anchors, so it has to stay readable in shadow rather than going
  // to a void. Sky bounce is what does that in the real yard too.
  const hemi = new THREE.HemisphereLight(0xbfd8ea, COLORS.grass, 1.8);
  scene.add(hemi);

  const sun = new THREE.DirectionalLight(0xfff2dc, 2.0);
  sun.position.copy(SUN_DIR).multiplyScalar(40);
  sun.target.position.set(0, 0, 0);
  sun.castShadow = true;
  sun.shadow.mapSize.set(2048, 2048);
  // Frame the yard tightly — a loose shadow frustum is why sail shadows go
  // soft and blocky, and the sail's shadow IS the product here.
  const sc = sun.shadow.camera;
  sc.left = -19; sc.right = 19; sc.top = 15; sc.bottom = -15;
  sc.near = 1; sc.far = 90;
  sun.shadow.bias = -0.0006;
  sun.shadow.normalBias = 0.02;
  scene.add(sun);
  scene.add(sun.target);

  // --- terrain -----------------------------------------------------------
  const groundGeo = new THREE.PlaneGeometry(YARD.width, YARD.depth, 60, 40);
  groundGeo.rotateX(-Math.PI / 2);
  const gpos = groundGeo.attributes.position;
  for (let i = 0; i < gpos.count; i++) {
    gpos.setY(i, heightAt(gpos.getX(i), gpos.getZ(i)));
  }
  gpos.needsUpdate = true;
  groundGeo.computeVertexNormals();
  const ground = new THREE.Mesh(
    groundGeo,
    new THREE.MeshStandardMaterial({ color: COLORS.grass, roughness: 0.95 }),
  );
  ground.name = 'ground';
  ground.receiveShadow = true;
  root.add(ground);
  // Deliberately NOT in `solids`: the ground is answered by heightAt(), which
  // is exact and free. Raycasting it would be 4800 triangles a frame to learn
  // something we already know in closed form.

  // --- house (north edge) ------------------------------------------------
  // Rear wall sits exactly on z = -10 so the fascia anchors have a round
  // number to live on. Lane E's house_yardside.glb replaces this group and
  // should keep fascia_anchor_* at these positions.
  //
  // SPRINT14 — ONLY WHEN THE SITE DECLARES A HOUSE. This graybox used to be
  // built unconditionally, and dress() only retires it when a house GLB loads,
  // so a site with no `house` key kept it forever. site_02_corner_block has no
  // house — it has two streets — and has therefore been shipping a 16 × 3 × 6 m
  // featureless grey slab across the whole north horizon of night 3, in
  // `solids`, on the public URL. Found by opening the corner block in the yard
  // editor and standing in it at eye height; nothing else in the repo looks
  // north from inside that yard, which is why five sprints of green tests never
  // said a word. The anchor loop below was already `HOUSE?.anchors ?? []` — the
  // data path was guarded and the geometry path was not.
  let house = null;
  if (HOUSE) {
    house = new THREE.Group();
    house.name = 'house_yardside';
    const wall = new THREE.Mesh(
      new THREE.BoxGeometry(16, 3.0, 6),
      new THREE.MeshStandardMaterial({ color: COLORS.house, roughness: 0.85 }),
    );
    wall.position.set(0, 1.5, -13);
    wall.castShadow = true;
    wall.receiveShadow = true;
    house.add(wall);

    const roof = new THREE.Mesh(
      new THREE.BoxGeometry(16.8, 0.22, 6.8),
      new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.7 }),
    );
    roof.position.set(0, 3.1, -13);
    roof.castShadow = true;
    house.add(roof);

    // The fascia line — the lie the player will be tempted by (DESIGN.md).
    const fascia = new THREE.Mesh(
      new THREE.BoxGeometry(16, 0.24, 0.12),
      new THREE.MeshStandardMaterial({ color: COLORS.trim, roughness: 0.6 }),
    );
    fascia.position.set(0, 2.72, -9.98);
    house.add(fascia);

    root.add(house);
    solids.push(wall, roof);
    graybox.house = house;
  }

  // Graybox stand-ins at the graybox's own spacing. dress() moves every one of
  // these onto the position Lane E baked, which is why the numbers here don't
  // matter and the IDS do — the site names them, and everything downstream
  // (Lane B's quads, D's prompts, the balance suite) refers to them by name.
  for (const [i, a] of (HOUSE?.anchors ?? []).entries()) {
    anchors.push(Object.assign(
      makeStaticAnchor(a.id, a.type ?? 'house', new THREE.Vector3(-5 + i * 5, 2.6, -9.9)),
      { work: a.work },
      a.node ? null : factoryExtras(a.type ?? 'house'),
    ));
  }

  // --- trees -------------------------------------------------------------
  // Two, on opposite shoulders, mirroring the prototype's tree placement.
  // Canopies are separate named nodes so they can be swayed independently —
  // Lane E's tree_gum_01.glb must keep `trunk` / `canopy_*` / `branch_anchor_*`.
  // (treeSpecs is site.trees — bound at the top of createWorld.)
  for (const spec of treeSpecs) {
    const y0 = heightAt(spec.x, spec.z);
    const tree = new THREE.Group();
    tree.name = `tree_${spec.id}`;
    tree.position.set(spec.x, y0, spec.z);

    const trunk = new THREE.Mesh(
      new THREE.CylinderGeometry(0.18, 0.28, spec.trunkH, 8),
      new THREE.MeshStandardMaterial({ color: COLORS.bark, roughness: 1 }),
    );
    trunk.name = 'trunk';
    trunk.position.y = spec.trunkH / 2;
    trunk.castShadow = true;
    tree.add(trunk);
    solids.push(trunk);

    const canopy = new THREE.Group();
    canopy.name = 'canopy';
    canopy.position.y = spec.trunkH;
    const blobMat = new THREE.MeshStandardMaterial({ color: COLORS.leaf, roughness: 1 });
    for (const [j, b] of [
      { x: 0, y: 0.7, z: 0, r: 2.1 },
      { x: 1.1, y: 0.1, z: 0.5, r: 1.4 },
      { x: -0.9, y: 0.3, z: -0.6, r: 1.5 },
    ].entries()) {
      const blob = new THREE.Mesh(new THREE.SphereGeometry(b.r, 12, 8), blobMat);
      blob.name = `canopy_${j}`;
      blob.position.set(b.x, b.y, b.z);
      blob.castShadow = true;
      canopy.add(blob);
    }
    tree.add(canopy);
    canopies.push({ group: canopy, phase: spec.phase, base: canopy.rotation.clone() });

    root.add(tree);
    graybox.trees.set(spec.id, tree);

    // The anchor is at a branch fork, not the canopy centre. One per entry in
    // the site's list; dress() moves each onto its baked branch_anchor_* node.
    for (const a of spec.anchors ?? []) {
      anchors.push(Object.assign(
        makeSwayAnchor(a.id, new THREE.Vector3(spec.x, y0 + spec.anchorY, spec.z), spec.phase, wind),
        { work: a.work },
      ));
    }
  }

  // --- posts -------------------------------------------------------------
  // Raked away from the yard centre, because that is the correct practice and
  // the shape should teach it before any text does (DESIGN.md: "rake the post
  // away from the load").
  // SPRINT3 decision 2: posts pulled in off the fence and a third added.
  //
  // The old pair sat at (-6, 7) and (5, 7.5), which put every rigging option in
  // the 70–192 m² range Lane B flagged — a sail that big pre-tensions itself
  // into a cascade at t=0.4 s before the wind has done anything, so the yard was
  // teaching the wrong lesson. Pulled in, plus p3, the same yard now offers 31
  // quads in the 18–45 m² band (8 of which shade a quarter of the bed or more).
  //
  // Worth knowing before anyone "fixes" it: the smallest quad that covers the
  // bed COMPLETELY is 59 m², and that is not a bug to tune away. The bed sits
  // 10 m off the house, so any house-to-post sail is ~16 m long, and covering a
  // 6 m bed with it costs you a sail the storm will take. Full shade is meant to
  // be the expensive answer; the small quads buy survival and pay in patchy
  // shade. That IS the design (DESIGN.md, "big flat low vs small twisted steep").
  // SPRINT6 gate 1: p4 is the ONE close anchor the balance pass allows, and its
  // position is measured rather than guessed — and the measurement moved with
  // the data. p4's swept position table now lives in backyard_01.json's `_why`,
  // beside the coordinate it justifies, because that is the number someone will
  // be tempted to nudge. (postSpecs is site.posts, bound at the top.)
  const RAKE = (8 * Math.PI) / 180;
  for (const spec of postSpecs) {
    const y0 = heightAt(spec.x, spec.z);
    // Lean away from the centre of the yard, in the XZ plane.
    const away = new THREE.Vector2(spec.x, spec.z).normalize();
    const top = new THREE.Vector3(
      spec.x + Math.sin(RAKE) * spec.h * away.x,
      y0 + Math.cos(RAKE) * spec.h,
      spec.z + Math.sin(RAKE) * spec.h * away.y,
    );

    const post = new THREE.Mesh(
      new THREE.CylinderGeometry(0.06, 0.08, spec.h, 8),
      new THREE.MeshStandardMaterial({ color: COLORS.steel, roughness: 0.5, metalness: 0.6 }),
    );
    post.name = `sail_post_${spec.id}`;
    post.position.set((spec.x + top.x) / 2, (y0 + top.y) / 2, (spec.z + top.z) / 2);
    post.quaternion.setFromUnitVectors(
      new THREE.Vector3(0, 1, 0),
      top.clone().sub(new THREE.Vector3(spec.x, y0, spec.z)).normalize(),
    );
    post.castShadow = true;
    root.add(post);
    solids.push(post);

    const footing = new THREE.Mesh(
      new THREE.CylinderGeometry(0.22, 0.26, 0.18, 10),
      new THREE.MeshStandardMaterial({ color: 0x9c9c96, roughness: 0.95 }),
    );
    footing.position.set(spec.x, y0 + 0.06, spec.z);
    footing.receiveShadow = true;
    root.add(footing);

    anchors.push(Object.assign(
      makeStaticAnchor(spec.id, spec.type ?? 'post', top),
      { work: spec.work },
      spec.node ? null : factoryExtras(spec.type ?? 'post'),
    ));
  }

  // --- structures (the carport, and whatever else a site tacks on) -------
  // These carry ANCHORS, which is the whole point: site_02's carport looks like
  // four free tie-offs and is the worst steel in the game. Graybox positions are
  // approximate; dress() moves each anchor onto the node Lane E baked, with the
  // rating_hint / collateral that make it a trap. The site's own asserts (E's
  // e.test) pin those ratings below the fascia's — do not "fix" them upward.
  const structures = new Map();
  /**
   * SPRINT12 — the house's collateral seam, taught rather than restructured.
   * E named the trap precisely: collateralFor()/wreckStructure() iterate
   * `structures`, and the house is NOT a structure — so the gutter stayed
   * unpriced and unswappable. The alternative was moving the house into
   * structures[] (it fits the shape), but the house is the one building with
   * extra jobs — graybox stand-in, north boundary, the thing validateSite and
   * two other lanes' code read at `site.house` — and re-homing it mid-sprint
   * to avoid two small branches is churn, not design. If a third house-like
   * thing ever appears, THEN generalise. `collateralKey` (E's field, baked in
   * the GLB, canonical in site JSON) names which collateral string this
   * price answers — the carport never needed it because there structure id
   * and collateral string are the same word.
   */
  const houseEntry = { spec: HOUSE, glb: null, wreck: null };
  const houseKey = () =>
    HOUSE ? (HOUSE.collateralKey ?? houseEntry.glb?.userData?.collateral_key ?? null) : null;

  /**
   * Which collateral string does this structure's price answer? SPRINT14 — the
   * carport was one editor click from being FREE, and E's new assert found it.
   *
   * `collateralFor`/`wreckStructure`/`isWrecked` used to match a key against a
   * structure's site-JSON **id**. site_02 happens to id its structure
   * `"carport"` and its anchors say `collateral:"carport"`, so it resolved —
   * by coincidence of naming, on a sample size of one yard, for five sprints.
   * The editor generates unique ids (`s1`, `s2`, …), so the very first carport
   * anyone places writes an id that is not `"carport"`; the anchors still say
   * `"carport"`, no structure has that id, and the $180 trap silently prices
   * to null. That is the gutter bug — a failure that reads as free — recurring
   * in the sprint meant to bury it.
   *
   * So structures resolve exactly the way the house already did: site JSON
   * canonical, GLB extra as fallback, and **the id last** so every existing
   * site keeps working unchanged. Same three-step, one shape, no special case.
   */
  const structKey = (entry) =>
    entry.spec.collateralKey ?? entry.glb?.userData?.collateral_key ?? entry.spec.id;

  /** Find a structure by collateral key, then by literal id. */
  const structFor = (key) => {
    if (!key) return null;
    for (const entry of structures.values()) if (structKey(entry) === key) return entry;
    return structures.get(key) ?? null;
  };
  for (const st of site.structures ?? []) {
    const marker = new THREE.Group();
    marker.name = st.id;
    marker.position.set(st.x, heightAt(st.x, st.z), st.z);
    marker.rotation.y = rad(st.rotYDeg);
    root.add(marker);
    structures.set(st.id, { spec: st, marker });
    for (const a of st.anchors ?? []) {
      // Graybox guess: near the structure origin, above head height. dress()
      // corrects it; until then the site is at least riggable in the selftest.
      anchors.push(Object.assign(
        makeStaticAnchor(a.id, a.type ?? 'post', new THREE.Vector3(st.x, heightAt(st.x, st.z) + 2.4, st.z)),
        { work: a.work },
        a.node ? null : factoryExtras(a.type ?? 'post'),
      ));
    }
  }

  // --- garden bed (the thing you are protecting) -------------------------
  const bed = new THREE.Group();
  bed.name = 'garden_bed';
  const bedY = heightAt(GARDEN_BED.x, GARDEN_BED.z);
  bed.position.set(GARDEN_BED.x, bedY, GARDEN_BED.z);

  const soil = new THREE.Mesh(
    new THREE.BoxGeometry(GARDEN_BED.w, 0.3, GARDEN_BED.d),
    new THREE.MeshStandardMaterial({ color: COLORS.soil, roughness: 1 }),
  );
  soil.position.y = 0.15;
  soil.receiveShadow = true;
  bed.add(soil);

  const plantMat = new THREE.MeshStandardMaterial({ color: COLORS.plant, roughness: 0.9 });
  for (let i = 0; i < 6; i++) {
    for (let j = 0; j < 4; j++) {
      const plant = new THREE.Mesh(new THREE.SphereGeometry(0.28, 8, 6), plantMat);
      plant.position.set(
        (-0.5 + (i + 0.5) / 6) * GARDEN_BED.w,
        0.42,
        (-0.5 + (j + 0.5) / 4) * GARDEN_BED.d,
      );
      plant.castShadow = true;
      plant.receiveShadow = true;
      bed.add(plant);
    }
  }
  root.add(bed);
  graybox.bed = bed;

  // --- boundary fence ----------------------------------------------------
  // East, south and west only: the house is the north boundary.
  const fence = new THREE.Group();
  fence.name = 'fence';
  const railMat = new THREE.MeshStandardMaterial({ color: 0x7a6a4f, roughness: 1 });
  const hx = YARD.width / 2, hz = YARD.depth / 2;
  const SIDES = {
    south: { from: [-hx, hz], to: [hx, hz] },
    west: { from: [-hx, -hz], to: [-hx, hz] },
    east: { from: [hx, -hz], to: [hx, hz] },
    north: { from: [-hx, -hz], to: [hx, -hz] },
  };
  // backyard_01 fences south/west/east — the house IS its north boundary. The
  // corner block has a street on two sides and fences the other two, which is
  // the whole reason this is a site field rather than three hardcoded runs.
  const runs = (site.fence?.sides ?? ['south', 'west', 'east']).map((s) => SIDES[s]).filter(Boolean);
  for (const run of runs) {
    const [x0, z0] = run.from, [x1, z1] = run.to;
    const len = Math.hypot(x1 - x0, z1 - z0);
    const n = Math.round(len / 2.5);
    for (let i = 0; i <= n; i++) {
      const f = i / n;
      const x = x0 + (x1 - x0) * f, z = z0 + (z1 - z0) * f;
      const p = new THREE.Mesh(new THREE.BoxGeometry(0.1, 1.6, 0.1), railMat);
      p.position.set(x, heightAt(x, z) + 0.8, z);
      p.castShadow = true;
      fence.add(p);
    }
    for (const ry of [0.6, 1.35]) {
      const rail = new THREE.Mesh(new THREE.BoxGeometry(len, 0.12, 0.04), railMat);
      rail.position.set((x0 + x1) / 2, heightAt((x0 + x1) / 2, (z0 + z1) / 2) + ry, (z0 + z1) / 2);
      rail.rotation.y = Math.atan2(-(z1 - z0), x1 - x0);
      rail.castShadow = true;
      fence.add(rail);
    }
  }
  root.add(fence);
  solids.push(fence);

  // --- shed & spare table ------------------------------------------------
  // Where the spare hardware lives, which makes it where the §7 scenario
  // starts: rig → carry a spare → repair mid-storm. Lane D's pickup radius is
  // 1.5 m off this point and everything downstream of it is already wired, so
  // this small thing gates the whole hand-played loop.
  //
  // The position is published SYNCHRONOUSLY, from constants, even though the
  // meshes arrive later in dress(). createWorld() has to stay sync — a.test.js
  // and the selftest build a yard without a server — and Lane D's
  // wireYardActions reads world.shedTable at wiring time. dress() refines the
  // point to Lane E's baked `pickup_anchor` if it's there.
  // SPRINT14: null when the site declares no table, rather than throwing on
  // `SHED_TABLE.x`. Both shipped sites have one, so this had never been reached
  // — the editor reaches it on its first frame, because the empty template has
  // no shed and you can delete the table off a yard. Every consumer already
  // guards it (`if (world.shedTable)` in interact.js, ladder.js, broom.js,
  // whose comment even says "until it lands, the pickup self-skips"), so the
  // null branch was designed for and merely unreachable. `shedTable` is not in
  // CONTRACT.world, so this stays contract-legal.
  const shedTable = SHED_TABLE ? {
    pos: new THREE.Vector3(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z) + 0.9, SHED_TABLE.z),
  } : null;

  /**
   * Show one of E's three wilt states. No-op against the graybox bed, so the
   * HUD can call it unconditionally.
   * @param {'full'|'tattered'|'dead'} which
   */
  function setPlants(which) {
    if (!plants) return;
    for (const [k, node] of Object.entries(plants)) {
      if (node) node.visible = k === which;
    }
  }

  /**
   * Swap Lane E's GLBs in over the graybox. Async and separate from
   * createWorld() on purpose: the selftest builds a yard with no server, and a
   * fetch in the constructor would either break it or make it slow and flaky.
   * Every load is individually guarded — a missing GLB leaves its graybox
   * standing rather than taking the boot down with it.
   */
  async function dress() {
    const { GLTFLoader } = await import('../vendor/addons/loaders/GLTFLoader.js');
    const loader = new GLTFLoader();

    /** Take a graybox stand-in out of the scene AND out of `solids`. */
    const retire = (obj) => {
      if (!obj) return;
      obj.traverse((o) => {
        const i = solids.indexOf(o);
        if (i >= 0) solids.splice(i, 1);
        o.geometry?.dispose();
      });
      const i = solids.indexOf(obj);
      if (i >= 0) solids.splice(i, 1);
      obj.parent?.remove(obj);
    };

    /**
     * Move an existing anchor onto the position Lane E baked, and take their
     * rating_hint with it. Mutates `pos` in place rather than reassigning it:
     * `interact.register` and Lane B's corners capture these vectors by
     * reference, and a reassign would leave them holding a stale one.
     */
    const adoptAnchor = (glb, nodeName, anchorId) => {
      const node = glb.getObjectByName(nodeName);
      const anchor = anchors.find((a) => a.id === anchorId);
      if (!node || !anchor) return false;
      anchor.pos.setFromMatrixPosition(node.matrixWorld);
      anchor.ratingHint = node.userData?.rating_hint ?? 1;
      anchor.collateral = node.userData?.collateral ?? null;
      // SPRINT14, E's gap 1: some baked anchor nodes are NOT tie-offs — a door
      // step, a bench top, a broom grip, a lighting hint. They carry no
      // `rating_hint`, and `?? 1` therefore rated them PERFECT: name one in a
      // site and it silently becomes the best steel in the yard. Carried onto
      // the anchor so the thing that can see it can say so (the editor's
      // validation panel); left LOUD rather than silently re-rated, because
      // inventing a number for "this is not an anchor" is how the trap became
      // honest steel in the first place.
      anchor.tieOff = node.userData?.tie_off !== false;
      if (anchor.tieOff === false) {
        console.warn(`[world] anchor ${anchorId} names '${nodeName}', which is baked tie_off:false `
          + '— that node is not steel and must not carry a sail.');
      }
      return true;
    };

    const load = async (name) => {
      if (!name) return null;              // the site simply doesn't have one
      try {
        const gltf = await loader.loadAsync(new URL(`../models/${name}.glb`, import.meta.url).href);
        gltf.scene.traverse((o) => {
          if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; }
          // Lane E's optional-node flag: glTF has no visibility bit and Blender's
          // hide_render does NOT survive export (THREADS [E] 2026-07-17 — the
          // garden bed drew all three wilt states superimposed for five sprints).
          if (o.userData?.hidden_by_default) o.visible = false;
        });
        return gltf.scene;
      } catch (err) {
        console.warn(`[world] ${name} unavailable, keeping graybox:`, err.message);
        return null;
      }
    };

    // Model names come off the site now. `load(null)` resolves null, so a site
    // that simply has no shed (site_02 has a carport) skips it without a branch.
    const [shed, table, houseGlb, bedGlb, gnome, bike] = await Promise.all([
      load(SHED?.model), load(SHED_TABLE?.model), load(HOUSE?.model),
      load(site.gardenBedModel ?? 'garden_bed_v1'), load(GNOME?.model),
      load(BIKE?.model),
    ]);
    const treeGlbs = await Promise.all(treeSpecs.map((s) => load(s.model)));

    // --- garden bed ------------------------------------------------------
    if (bedGlb) {
      retire(graybox.bed);
      bedGlb.name = 'garden_bed';
      bedGlb.position.set(GARDEN_BED.x, heightAt(GARDEN_BED.x, GARDEN_BED.z), GARDEN_BED.z);
      root.add(bedGlb);
      const pick = (n) => bedGlb.getObjectByName(n) ?? null;
      plants = { full: pick('plants_full'), tattered: pick('plants_tattered'), dead: pick('plants_dead') };
      setPlants('full');
    }

    // --- gnome (aftermath collateral bait) -------------------------------
    // Placed under the sail's likely footprint on purpose: DESIGN.md wants your
    // own failures to be the worst debris, and something breakable has to be
    // standing there for that to land.
    if (gnome) {
      gnome.name = 'garden_gnome_01';
      gnome.position.set(GNOME.x, heightAt(GNOME.x, GNOME.z), GNOME.z);
      gnome.rotation.y = GNOME.rotY;
      root.add(gnome);
    }

    // --- the kid's bike (per-client juice, SPRINT12) ----------------------
    // The lean is baked into the GLB toward local −Z (E's export note — in
    // Blender it leans +Y; the exporter maps (x,y,z)→(x,z,−y)), so placement's
    // ONE job is to point that lean at the fence. rotY comes from site data;
    // the site's _why records the measured bars-to-rail arithmetic. Not in
    // solids — you can walk past a kid's bike — and not billed: unpriced by
    // ruling until wind can knock it over.
    if (bike) {
      bike.name = 'bike_kid_01';
      bike.position.set(BIKE.x, heightAt(BIKE.x, BIKE.z), BIKE.z);
      bike.rotation.y = BIKE.rotY;
      root.add(bike);
    }

    // --- house (decision 6: no re-cut, the GLB's data wins) ---------------
    // E's fascia sits at 2.80 m and their anchors span x=-3..3, where my
    // graybox guessed 2.6 m and -5..5. Reading them narrows the house span by
    // 4 m, which is a real part of why the yard now offers small quads at all.
    // Every fascia anchor carries rating_hint 0.35 — E encoded DESIGN.md's
    // "the fascia board is a lie" straight into the asset, and `collateral:
    // "gutter"` says what it takes with it when it goes.
    if (houseGlb) {
      retire(graybox.house);
      houseGlb.name = 'house_yardside';
      houseGlb.position.set(HOUSE.x, heightAt(HOUSE.x, HOUSE.z), HOUSE.z);
      root.add(houseGlb);
      solids.push(houseGlb);
      houseGlb.updateWorldMatrix(true, true);
      for (const a of HOUSE.anchors ?? []) adoptAnchor(houseGlb, a.node, a.id);
      houseEntry.glb = houseGlb;

      // SPRINT12 — the torn-gutter wreck, the carport pattern on the one
      // building that is not a structure. Loaded now and parked invisible for
      // the same reason the carport's is: the swap happens as the storm ends,
      // and an async load there drops the payoff frames after the card.
      if (HOUSE.wreckedModel) {
        const wreck = await load(HOUSE.wreckedModel);
        if (wreck) {
          wreck.name = 'house_yardside_wrecked';
          wreck.position.copy(houseGlb.position);
          wreck.visible = false;
          root.add(wreck);
          houseEntry.wreck = wreck;
        }
      }
    }

    // --- trees -----------------------------------------------------------
    // Each tree ships 2-3 branch anchors with descending rating_hint (1.0 at
    // the fork, 0.76 out where the limb is thin) — the intel DESIGN.md wants
    // inspection to buy. branch_anchor_01 keeps the original t1/t2 id so
    // nothing that already references them breaks; the rest are added.
    for (const [i, spec] of treeSpecs.entries()) {
      const glb = treeGlbs[i];
      if (!glb) continue;
      const old = graybox.trees.get(spec.id);
      retire(old);
      // The graybox canopy was what world.update() swayed — hand that job over.
      const idx = canopies.findIndex((c) => old && old.getObjectByName('canopy') === c.group);
      if (idx >= 0) canopies.splice(idx, 1);

      glb.name = `tree_${spec.id}`;
      glb.position.set(spec.x, heightAt(spec.x, spec.z), spec.z);
      root.add(glb);
      const trunk = glb.getObjectByName('trunk');
      if (trunk) solids.push(trunk);
      const canopy = glb.getObjectByName('canopy_01') || glb.getObjectByName('canopy');
      if (canopy?.parent) {
        canopies.push({ group: canopy.parent, phase: spec.phase, base: canopy.parent.rotation.clone() });
      }

      // Every branch anchor the site names now EXISTS before dress() runs (they
      // were created off spec.anchors above), so this only has to move them onto
      // the nodes Lane E baked. That deleted the old suffix-guessing loop, which
      // built t1/t1b/t1c out of an array index and could only ever describe the
      // two trees it was written for.
      glb.updateWorldMatrix(true, true);
      for (const a of spec.anchors ?? []) adoptAnchor(glb, a.node, a.id);
    }

    if (shed) {
      shed.name = 'shed_01';
      shed.position.set(SHED.x, heightAt(SHED.x, SHED.z), SHED.z);
      shed.rotation.y = SHED.rotY;
      root.add(shed);
      solids.push(shed);
    }
    if (table) {
      table.name = 'shed_table';
      table.position.set(SHED_TABLE.x, heightAt(SHED_TABLE.x, SHED_TABLE.z), SHED_TABLE.z);
      table.rotation.y = SHED_TABLE.rotY;
      root.add(table);
      // NOT in solids: you want to walk up to the table, not be fenced off it.

      // Prefer Lane E's baked anchor over my guess at where a table top is.
      table.updateWorldMatrix(true, true);
      const anchor = table.getObjectByName('pickup_anchor');
      if (anchor) shedTable.pos.setFromMatrixPosition(anchor.matrixWorld);
    }

    // --- structures ------------------------------------------------------
    // The carport, and its trap. Load the GLB, drop it on its graybox marker,
    // and move each anchor onto the node E baked — carrying the rating_hint and
    // collateral that make it the worst tie-off in the game. adoptAnchor reads
    // both off userData, so the "0.22 beam" trap arrives with the geometry.
    for (const st of site.structures ?? []) {
      const entry = structures.get(st.id);
      const glb = await load(st.model);
      if (!glb) continue;
      retire(entry.marker);
      glb.name = st.id;
      glb.position.set(st.x, heightAt(st.x, st.z), st.z);
      glb.rotation.y = rad(st.rotYDeg);
      root.add(glb);
      if (st.solid) glb.traverse((o) => { if (o.isMesh) solids.push(o); });
      glb.updateWorldMatrix(true, true);
      for (const a of st.anchors ?? []) adoptAnchor(glb, a.node, a.id);
      entry.glb = glb;

      // SPRINT11 — the wreck, loaded NOW and parked invisible. E built it to the
      // same origin and footprint so it swaps mesh-for-mesh (their e.test pins
      // that, and that it carries the same price and names its intact twin).
      // Loaded during dress rather than at the moment it's needed because the
      // swap happens as the storm ends: an async load there would drop the
      // payoff frames after the aftermath card, or not at all offline.
      if (st.wreckedModel) {
        const wreck = await load(st.wreckedModel);
        if (wreck) {
          wreck.name = `${st.id}_wrecked`;
          wreck.position.copy(glb.position);
          wreck.rotation.y = glb.rotation.y;
          wreck.visible = false;
          root.add(wreck);
          entry.wreck = wreck;
        }
      }
    }

    return { shed, table };
  }

  // --- the world object --------------------------------------------------
  return {
    anchors,
    heightAt,
    gardenBed: GARDEN_BED,
    sunDir: SUN_DIR.clone(),
    solids,
    root,
    /**
     * Where a spare gets picked up. `{pos}` — Lane D registers a 1.5 m hold-E
     * off this point. Present from construction; dress() may nudge it onto
     * Lane E's `pickup_anchor`.
     */
    shedTable,
    dress,
    /** @param {'full'|'tattered'|'dead'} which */
    setPlants,
    /** Where the breakable client property stands, and what breaking it costs. */
    gnome: GNOME,
    // Lane C's skyfx MODULATES these as the storm builds and hands them back
    // untouched on dispose() — it doesn't own them. That's why the yard exposes
    // its lights rather than keeping them private.
    sun,
    hemi,

    /** @param {string} id */
    anchor(id) {
      return anchors.find((a) => a.id === id) ?? null;
    },

    /**
     * What the client's property costs you when your rig takes it with it.
     *
     * The key is the string E bakes as `collateral` on the anchor (site_02's
     * carport anchors all say "carport"), so the chain is: tie to the beam →
     * the beam's anchor carries `collateral:"carport"` → this prices it. The
     * PRICE lives in site JSON, not the GLB: sites are data, and what a carport
     * costs is this site's economy, not a property of the mesh. E's baked
     * `collateral_value` is the fallback and their proposal (SPRINT11: 180,
     * adopted with their reasoning).
     *
     * Returns null for an unpriced label, and that is deliberate rather than a
     * zero: the house's fascia anchors carry `collateral:"gutter"` and NOBODY
     * has priced a gutter yet. A missing price must read as "not scored", never
     * as "free" — see THREADS, it's an open seam.
     *
     * @param {string} key
     * @returns {{cost:number, label:string}|null}
     */
    collateralFor(key) {
      if (!key) return null;
      const entry = structFor(key);
      if (entry) {
        const { spec, glb } = entry;
        const cost = spec.collateralValue ?? glb?.userData?.collateral_value ?? null;
        if (!Number.isFinite(cost)) return null;
        return { cost, label: spec.collateralLabel ?? glb?.userData?.collateral_label ?? spec.id };
      }
      // SPRINT12 — the gutter (A's ruling: $90, E's proposal adopted; the
      // reasoning is in backyard_01.json beside the number). Same contract as
      // structures: site JSON canonical, GLB extras fallback, null when
      // unpriced — a site whose house declares no collateralKey still reads
      // "not scored", never "free".
      if (HOUSE && key === houseKey()) {
        const cost = HOUSE.collateralValue ?? houseEntry.glb?.userData?.collateral_value ?? null;
        if (!Number.isFinite(cost)) return null;
        return { cost, label: HOUSE.collateralLabel ?? houseEntry.glb?.userData?.collateral_label ?? key };
      }
      return null;
    },

    /**
     * Swap a structure for its wreck. The payoff for the trap: you don't break a
     * shackle, you take the carport, and the aftermath has to SHOW that.
     * No-op (returns false) when the site declares no wreck or the GLB is
     * missing — the graybox and headless paths still score the bill, they just
     * can't show it. Idempotent.
     * Takes a COLLATERAL KEY (what main.js reads off the blown anchor), or a
     * literal structure id — `structFor` resolves both. See `structKey`.
     * @param {string} id
     */
    wreckStructure(id) {
      const entry = structFor(id);
      if (entry?.wreck && entry.glb) {
        entry.glb.visible = false;
        entry.wreck.visible = true;
        return true;
      }
      // The house, keyed by its collateral string ('gutter'): the gutter
      // unzips, the house stands. E's window_glow warning honoured — the wreck
      // ships its glow hidden_by_default under the same name, so the swap
      // copies the intact house's current glow state or the client's lights
      // would go out with the gutter if this ever lands mid-night.
      if (HOUSE && id === houseKey() && houseEntry.wreck && houseEntry.glb) {
        const glowWas = houseEntry.glb.getObjectByName('window_glow')?.visible ?? false;
        const glow = houseEntry.wreck.getObjectByName('window_glow');
        if (glow) glow.visible = glowWas;
        houseEntry.glb.visible = false;
        houseEntry.wreck.visible = true;
        return true;
      }
      return false;
    },

    /** Is this structure standing? Lane D asked for a poke-able truth. */
    isWrecked(id) {
      if (HOUSE && id === houseKey()) return houseEntry.wreck?.visible === true;
      return structFor(id)?.wreck?.visible === true;
    },

    /**
     * Take the whole yard out of the scene and free its GPU memory. SPRINT10:
     * the week rebuilds the world when the site changes between nights, and
     * without this each new yard leaks the last one's geometry and its lights
     * pile up in the scene. Removes the root and both lights (skyfx borrows the
     * lights and hands them back on its own dispose, so by here they're ours).
     */
    dispose() {
      root.traverse((o) => { o.geometry?.dispose?.(); o.material?.dispose?.(); });
      root.parent?.remove(root);
      sun.parent?.remove(sun);
      sun.target.parent?.remove(sun.target);
      hemi.parent?.remove(hemi);
    },

    update(dt, t) {
      // Canopies lean with the wind they actually stand in — this is the tell
      // the player reads a gust front from, a beat before it reaches the sail.
      for (const c of canopies) {
        const w = wind.sample(c.group.getWorldPosition(_worldPos), t);
        const speed = w.length();
        const lean = Math.min(0.22, speed * 0.007);
        const flutter = 0.55 + 0.45 * Math.sin(t * 2.3 + c.phase);
        c.group.rotation.z = c.base.z - Math.cos(Math.atan2(w.z, w.x)) * lean * flutter;
        c.group.rotation.x = c.base.x + Math.sin(Math.atan2(w.z, w.x)) * lean * flutter;
      }
    },
  };
}

const _worldPos = new THREE.Vector3();

/** @returns {import('./contracts.js').Anchor} */
function makeStaticAnchor(id, type, pos) {
  const p = pos.clone();
  return { id, type, pos: p, sway: () => p, ratingHint: DEFAULT_RATING_HINT };
}

/**
 * A tree anchor. Wanders with the wind it stands in, which is exactly why it is
 * the dangerous choice: the sway is dynamic load the rig has to eat, and a
 * drum-tight rig on a swaying tree snaps turnbuckles (DESIGN.md).
 *
 * @returns {import('./contracts.js').Anchor}
 */
function makeSwayAnchor(id, pos, phase, wind) {
  const p = pos.clone();
  const scratch = new THREE.Vector3();   // per-anchor, so two anchors never alias
  return {
    id,
    type: 'tree',
    pos: p,
    ratingHint: DEFAULT_RATING_HINT,
    sway(t) {
      const speed = wind.sample(p, t).length();
      const amp = Math.min(0.35, speed * 0.012);
      const s = Math.sin(t * 1.9 + phase);
      return scratch.set(
        p.x + s * amp,
        p.y - Math.abs(s) * amp * 0.25,
        p.z + Math.cos(t * 1.3 + phase) * amp * 0.5,
      );
    },
  };
}
