/**
 * SHADES / HARD YARDS — the yard editor. Lane A owns this file (SPRINT14 gate 1).
 *
 * docs/MANUAL.md's "authoring a site" cookbook, with the typing removed. It
 * renders the REAL dressed world through the SAME `loadSite`/`createWorld`
 * path the game boots, so what you place is what you play — this is not a
 * diagram and there is deliberately no second renderer to keep in sync.
 *
 * Two rules it exists to enforce, both of them from THREADS:
 *
 *  1. **No parallel schema.** Placement writes the same named-key site-JSON
 *     shapes `world.js` already reads. There is no generic `props[]` in this
 *     repo on purpose (the gnome pattern) and the editor does not invent one.
 *     If you can't express a thing as a named key, the answer is a loader
 *     change plus a palette entry — not an editor-private format.
 *  2. **The editor cannot make a yard the game can't boot.** `validateSite`
 *     runs on every edit into a panel (not the console), an invalid yard
 *     exports under a loud `_INVALID` key, and a.test round-trips a placed
 *     yard through export → `loadSite` → `createWorld` → `dress` every run.
 *
 * ── THE SEAM API (Lane B's SCORE IT, Lane C's wind authoring) ───────────────
 * `globalThis.EDITOR`, also the resolved value of `createEditor()`:
 *
 *   scene, renderer, camera        three.js handles (camera is a PerspectiveCamera)
 *   world                          the live world from createWorld (null before first build)
 *   site                           the LIVE in-memory site object — mutate it, then markDirty()
 *   siteClone()                    deep clone, canonically ordered (what export would write)
 *   setSite(obj)                   → Promise; replace the whole site and rebuild
 *   rebuild()                      → Promise; re-run createWorld + dress against `site`
 *   markDirty()                    re-validate + re-render panels + fire 'change'
 *   validation()                   → { ok, errors:string[], warnings:string[] }
 *   exportJSON()                   → canonical JSON string (stable key order, 2-space)
 *   on(evt, fn) / off(evt, fn)     'change' | 'rebuild' | 'select' | 'siteload'
 *   mountPanel({id,title,order})   → { root, body } — idempotent per id; see editor.html
 *   overlay                        THREE.Group, CLEARED ON EVERY REBUILD — put gizmos here
 *   raycastGround(ev)              → {x,y,z} | null  (y = heightAt(x,z))
 *   registerTool(spec)/setTool(id) pointer tools; see `registerTool` below
 *   select(ref)/selection          ref = {kind, id} — see REFS
 *   HEIGHT_AT                      world.js's heightAt, re-exported for gizmo placement
 *
 * Events carry `{ site }` (change/siteload), `{ world, site }` (rebuild) or
 * `{ ref }` (select). C: mutate `EDITOR.site.wind` then `markDirty()` — the
 * wind block round-trips through the same canonical export as everything else,
 * and `validateSiteWind` is already wired into `validateSite`, so a bad gain
 * lands in the validation panel for free.
 *
 * ── WHY REBUILD-ON-DROP ────────────────────────────────────────────────────
 * Dragging translates the entry's scene nodes live (cheap, one object). The
 * WORLD is rebuilt on drop, because anchors are the thing downstream cares
 * about and only `createWorld` + `dress` produce them honestly: a GLB-backed
 * anchor's position and its `rating_hint` come out of the asset, not out of
 * arithmetic the editor could do itself. Re-deriving them here would be a
 * fourth harness rebuilding site truth by hand — the exact mistake
 * docs/MANUAL.md's `windForSite()` gotcha is a monument to. Measured on this
 * machine: a warm rebuild of backyard_01 is ~40-90 ms, which is a drop, not a
 * drag. If that ever stops being true, cache by model name — don't re-derive.
 */

import * as THREE from '../vendor/three.module.js';
import { ANCHOR_TYPE, createStubWind } from './contracts.js';
import { createWorld, heightAt, loadSite, validateSite } from './world.js';

// ---------------------------------------------------------------------------
// Canonical serialisation — "same yard in, byte-identical JSON out"
// ---------------------------------------------------------------------------

/**
 * Key order per shape. Site diffs are reviewed by humans, so a key that moves
 * because an editor session happened to set it second is noise that hides the
 * one number that actually changed.
 *
 * The rule is total and therefore deterministic: keys named here come first in
 * THIS order, then every remaining key sorted alphabetically. Insertion order
 * is never consulted — it isn't stable across a load/edit/export cycle, which
 * is precisely what "byte-identical" has to survive.
 *
 * `_`-prefixed authoring notes are listed in place rather than swept to the
 * end: this repo expects levels to carry their design notes, and a `_design`
 * block that reads BEFORE the geometry it explains is the reason the corner
 * block's carport is still legible three sprints later.
 */
const KEY_ORDER = {
  $root: [
    'id', 'name', 'blurb', '_blurb', '_design', '_why',
    'yard', 'sun', 'gardenBed', 'gardenBedModel',
    'house', 'structures', 'trees', 'posts',
    'fence', 'shed', 'shedTable', 'gnome', 'bike',
    'separation', 'wind',
  ],
  yard: ['width', 'depth'],
  sun: ['elevationDeg', 'azimuthDeg'],
  gardenBed: ['x', 'z', 'w', 'd'],
  house: [
    'model', 'wreckedModel', 'collateralKey', 'collateralValue', 'collateralLabel',
    '_collateral', 'x', 'z', 'rotYDeg', 'anchors',
  ],
  structure: [
    'id', 'model', 'wreckedModel', 'x', 'z', 'rotYDeg', 'solid',
    'collateralKey', 'collateralValue', 'collateralLabel', '_collateral', '_types', 'anchors',
  ],
  tree: ['id', 'model', 'x', 'z', 'rotYDeg', 'phase', 'trunkH', 'anchorY', '_why', 'anchors'],
  anchor: ['id', 'node', 'type', 'work'],
  post: ['id', 'x', 'z', 'h', 'type', 'work'],
  fence: ['sides'],
  shed: ['model', 'x', 'z', 'rotYDeg'],
  shedTable: ['model', 'x', 'z', 'rotYDeg', 'pickupNode'],
  gnome: ['model', 'x', 'z', 'rotYDeg', 'collateralValue', '_why'],
  bike: ['model', 'x', 'z', 'rotYDeg', '_why'],
  wind: ['_venturi', 'venturi'],
  venturi: ['x', 'z', 'axis', 'gain', 'radius', 'sharp'],
  separation: ['_why', '_playedReference', 'stormKey', 'tension', 'line',
               'heldMustExceed', 'bareMustLoseBelow', 'separationAtLeast'],
  line: ['anchor', 'hw'],
};

/** Which KEY_ORDER table applies to the objects inside a given key. */
const CHILD_SHAPE = {
  structures: 'structure', trees: 'tree', posts: 'post', anchors: 'anchor',
  venturi: 'venturi', line: 'line',
};

/**
 * Deep-clone `value` with every object's keys in canonical order.
 * Pure: it reads, it never rounds. Rounding happens at EDIT time (see
 * `round3`) so that export stays a plain serialisation and the in-memory site
 * and the exported file can never disagree about a coordinate.
 */
function canonical(value, shape = '$root') {
  if (Array.isArray(value)) {
    const child = CHILD_SHAPE[shape] ?? shape;
    return value.map((v) => canonical(v, child));
  }
  if (value === null || typeof value !== 'object') return value;
  const order = KEY_ORDER[shape] ?? [];
  const known = order.filter((k) => Object.hasOwn(value, k));
  const rest = Object.keys(value).filter((k) => !order.includes(k)).sort();
  const out = {};
  // A child's shape name is its KEY. That single rule covers both cases:
  // `yard` finds KEY_ORDER.yard, and `posts` reaches the array branch which
  // maps it through CHILD_SHAPE to `post`. An unknown key finds no table and
  // sorts alphabetically, which is still deterministic.
  for (const k of [...known, ...rest]) out[k] = canonical(value[k], k);
  return out;
}

/**
 * The export. `_INVALID` first and unmissable when the yard doesn't validate —
 * an invalid site must still be exportable (you save your work mid-build) but
 * it must not be able to land in `data/sites/` looking clean. `loadSite` would
 * throw on it anyway; the key is there so a HUMAN sees it in the diff first.
 *
 * @param {object} site
 * @param {{ok:boolean, errors:string[]}} [validation]
 * @returns {string}
 */
export function exportSiteJSON(site, validation) {
  const body = canonical(site, '$root');
  let out = body;
  // Bounds faults are the SECOND loud key (SPRINT15 gate 3.2): the game will
  // boot this yard — validateSite is structural — but an object outside the
  // fence is almost never what the author meant, and "the yard still reads
  // VALID" was exactly D's complaint. Not folded into _INVALID because that
  // key's sentence ("the game cannot boot it") would then sometimes lie.
  const bounds = validation?.bounds ?? [];
  if (bounds.length) {
    out = { _OUT_OF_BOUNDS: [
      'These sit outside the yard rectangle. The game boots this, but check it',
      'is what you meant before it goes near web/world/data/sites/:',
      ...bounds,
    ], ...out };
  }
  if (validation && !validation.ok) {
    out = { _INVALID: [
      'THIS YARD DOES NOT VALIDATE — the game cannot boot it. Exported so you',
      'do not lose the work. Fix these and re-export before it goes anywhere',
      'near web/world/data/sites/:',
      ...validation.errors,
    ], ...out };
  }
  return `${JSON.stringify(out, null, 2)}\n`;
}

/**
 * Everything placed outside the yard rectangle, as human sentences.
 * Centre-point check on purpose: footprint-aware bounds need per-model dims
 * the palette doesn't carry, and D's gate-3 repro (a placement metres past
 * the fence, silent) is a centre-point miss. The garden bed is the exception
 * — it's an axis-aligned rect whose w/d we DO know, so its edges count.
 * Exported for a.test.  [SPRINT15 gate 3.2]
 */
export function boundsFaults(site) {
  const w = site.yard?.width, d = site.yard?.depth;
  if (!(w > 0) || !(d > 0)) return [];
  const hw = w / 2, hd = d / 2;
  const out = [];
  const check = (label, x, z, exX = 0, exZ = 0) => {
    if (!Number.isFinite(x) || !Number.isFinite(z)) return;
    if (Math.abs(x) + exX > hw + 1e-9 || Math.abs(z) + exZ > hd + 1e-9) {
      out.push(`${label} at (${x}, ${z}) is outside the ${w}×${d} m yard`);
    }
  };
  for (const p of site.posts ?? []) check(`post ${p.id}`, p.x, p.z);
  for (const t of site.trees ?? []) check(`tree ${t.id}`, t.x, t.z);
  for (const s of site.structures ?? []) check(`structure ${s.id}`, s.x, s.z);
  for (const k of ['house', 'shed', 'shedTable', 'gnome', 'bike']) {
    const e = site[k];
    if (e) check(k, e.x, e.z);
  }
  const g = site.gardenBed;
  if (g) check('the garden bed', g.x, g.z, (g.w ?? 0) / 2, (g.d ?? 0) / 2);
  return out;
}

/** Coordinates are rounded where they're WRITTEN, so export stays pure. */
const round3 = (n) => Math.round(n * 1000) / 1000;

// ---------------------------------------------------------------------------
// The palette — every placeable, and the exact named-key shape it writes
// ---------------------------------------------------------------------------

/**
 * Node names are the ones actually baked in the committed GLBs (read out of
 * the files, not remembered). They are checked after every rebuild by
 * `adoptionWarnings()`: a palette entry that names a node the asset doesn't
 * have would otherwise leave the anchor sitting at its graybox position with
 * `ratingHint` 1.0 — a trap that silently becomes honest steel, which is the
 * invincible-house gotcha wearing a different hat.
 */
export const PALETTE = [
  {
    kind: 'post', label: 'Post', hint: 'an honest ground post — the cheap, safe anchor',
    make: (id, x, z) => ({ id, x, z, h: 4.0, type: 'post', work: 'cloth' }),
  },
  {
    kind: 'tree', label: 'Gum (big, 3 anchors)', model: 'tree_gum_01_v1',
    hint: 'sways — dynamic load, which is why a tree is scarier than a post',
    make: (id, x, z) => ({
      id, model: 'tree_gum_01_v1', x, z, phase: 0.7, trunkH: 4.2, anchorY: 3.4,
      anchors: [1, 2, 3].map((i) => ({
        id: i === 1 ? id : `${id}${'abc'[i - 1]}`,
        node: `branch_anchor_0${i}`, type: 'tree', work: 'cloth',
      })),
    }),
  },
  {
    kind: 'tree', label: 'Gum (smaller, 2 anchors)', model: 'tree_gum_02_v1',
    hint: 'shorter reach; the corner block\'s honest anchor',
    make: (id, x, z) => ({
      id, model: 'tree_gum_02_v1', x, z, phase: 1.7, trunkH: 3.8, anchorY: 3.1,
      anchors: [1, 2].map((i) => ({
        id: i === 1 ? id : `${id}b`,
        node: `branch_anchor_0${i}`, type: 'tree', work: 'cloth',
      })),
    }),
  },
  {
    kind: 'structure', label: 'Carport (the trap)', model: 'carport_01_v1',
    hint: 'four anchors that look free; rating_hint 0.22/0.30 and it takes $180 with it',
    make: (id, x, z) => ({
      id, model: 'carport_01_v1', wreckedModel: 'carport_01_wrecked_v1',
      x, z, rotYDeg: 0, solid: true,
      // `collateralKey` is written EXPLICITLY and is not optional here. The
      // anchors say `collateral: "carport"`; the structure's id is whatever
      // the editor generated (s1, s2, …). Without this line the $180 prices to
      // null and the trap becomes a free failure — E caught it, and it is the
      // gutter bug wearing the editor's hat. site JSON canonical, GLB extra as
      // the fallback: the same contract as the price beside it.
      collateralKey: 'carport',
      collateralValue: 180, collateralLabel: 'the carport',
      anchors: [
        { id: `${id}_b1`, node: 'beam_anchor_01', type: 'carport', work: 'bracket' },
        { id: `${id}_b2`, node: 'beam_anchor_02', type: 'carport', work: 'bracket' },
        { id: `${id}_p1`, node: 'post_anchor_01', type: 'carport_post', work: 'cloth' },
        { id: `${id}_p2`, node: 'post_anchor_02', type: 'carport_post', work: 'cloth' },
      ],
    }),
  },
  {
    // SPRINT14 gate 3.1, E's "honest middle". Two apex anchors at 0.45 —
    // better steel than the house fascia (0.35), worse than a gum fork — and a
    // crossbar that is the most anchor-looking object in the game and carries
    // `tie_off: false`. $140 is E's number, ADOPTED unchanged; the ruling and
    // the one condition for revisiting it are in THREADS.
    // ⚠️ The `_v1` suffix is LOAD-BEARING and its absence is silent. These two
    // entries shipped without it (SPRINT14, D's cold pass): the GLB 404s, the
    // prop never draws, and because `adoptAnchor` does `rating_hint ?? 1` a
    // MISSING MODEL BECOMES THE BEST STEEL IN THE GAME — the swing apexes read
    // 1.00 instead of E's 0.45, the jacaranda 1.00/1.00/1.00 instead of
    // 0.95/0.52/0.40 — and the yard still validates VALID. Same species as E's
    // `tie_off` audit: silence is not neutral where a default is generous.
    kind: 'structure', label: 'Swing set (the honest middle)', model: 'swing_set_01_v1',
    requires: ['swing_frame'],
    hint: 'apex anchors 0.45; the crossbar LOOKS like the answer and is not an anchor',
    make: (id, x, z) => ({
      id, model: 'swing_set_01_v1', wreckedModel: 'swing_set_01_wrecked_v1',
      x, z, rotYDeg: 0, solid: false,
      collateralKey: 'swing_set',
      collateralValue: 140, collateralLabel: 'the swing set',
      anchors: [1, 2].map((i) => ({
        id: `${id}_f${i}`, node: `frame_anchor_0${i}`, type: 'swing_frame', work: 'cloth',
      })),
    }),
  },
  {
    // The ladder IS the feature: 0.95 low, then 0.52 / 0.40 — reach for height
    // on a jacaranda and you pay 58% for it, against the gum's 24%. Unpriced by
    // E's own ruling (no limb-failure event to watch; billing an unseen event
    // is the lie the invoice exists to kill). Same node names as the gums.
    kind: 'tree', label: 'Jacaranda (height costs)', model: 'tree_jacaranda_01_v1',
    hint: 'forks low and strong, then falls off a cliff — the "which tree" question',
    make: (id, x, z) => ({
      id, model: 'tree_jacaranda_01_v1', x, z, phase: 1.1, trunkH: 3.4, anchorY: 2.4,
      anchors: [1, 2, 3].map((i) => ({
        id: i === 1 ? id : `${id}${'abc'[i - 1]}`,
        node: `branch_anchor_0${i}`, type: 'tree', work: 'cloth',
      })),
    }),
  },
  {
    kind: 'house', label: 'House (north edge)', model: 'house_yardside_v1', singleton: true,
    hint: 'three fascia anchors at rating_hint 0.35 — "the fascia board is a lie"',
    make: (_id, x, z) => ({
      model: 'house_yardside_v1', wreckedModel: 'house_yardside_wrecked_v1',
      collateralKey: 'gutter', collateralValue: 90, collateralLabel: 'the gutter',
      x, z,
      anchors: [1, 2, 3].map((i) => ({
        id: `h${i}`, node: `fascia_anchor_0${i}`, type: 'house', work: 'bracket',
      })),
    }),
  },
  {
    kind: 'shed', label: 'Shed', model: 'shed_01_v1', singleton: true,
    hint: 'scenery — no rig anchors (its only node is door_anchor)',
    make: (_id, x, z) => ({ model: 'shed_01_v1', x, z, rotYDeg: -90 }),
  },
  {
    kind: 'shedTable', label: 'Spare table', model: 'shed_table_v1', singleton: true,
    hint: 'where a spare gets picked up — no table, no mid-storm repair',
    make: (_id, x, z) => ({ model: 'shed_table_v1', x, z, rotYDeg: -90, pickupNode: 'pickup_anchor' }),
  },
  {
    kind: 'gnome', label: 'Gnome ($25)', model: 'garden_gnome_01_v1', singleton: true,
    hint: 'breakable client property — the gnome pattern, priced in the SITE',
    make: (_id, x, z) => ({ model: 'garden_gnome_01_v1', x, z, rotYDeg: 0, collateralValue: 25 }),
  },
  {
    kind: 'bike', label: "Kid's bike", model: 'bike_kid_01_v1', singleton: true,
    hint: 'deliberately unpriced until the sim can knock it over (a tripwire pins this)',
    make: (_id, x, z) => ({ model: 'bike_kid_01_v1', x, z, rotYDeg: 180 }),
  },
];

/** Singletons the template always carries and you may move but not delete. */
const UNDELETABLE = new Set(['gardenBed']);

/**
 * The empty template — SPRINT14 gate 1.1's "flat yard + fence ring + garden bed",
 * PLUS one post, and the post is not padding.
 *
 * `validateSite` rejects a yard with no anchors ("nothing to rig to"), and
 * `createWorld` validates before it builds anything — so a literally
 * anchor-less template cannot be rendered AT ALL. Shipped as spec'd, the
 * editor's front door was a black viewport with an error in the corner, which
 * I only found by opening it. One post is the smallest thing that is a yard
 * rather than a rectangle, and it makes the first frame a place you can stand.
 *
 * The lesson the anchor-less template was meant to teach survives, in a better
 * place: delete this post and the validation panel says why, in those words,
 * while the last valid yard stays on screen so you can put it back.
 */
export function emptyTemplate() {
  return {
    id: 'site_new',
    name: 'Untitled yard',
    blurb: '',
    _design: [
      'Why this yard exists, what the trap is, and what you measured. Sites are',
      'data and this block is the level\'s design doc — docs/MANUAL.md step 1.',
    ],
    yard: { width: 24, depth: 16 },
    sun: { elevationDeg: 55, azimuthDeg: -125 },
    gardenBed: { x: 0, z: 0, w: 5, d: 3.5 },
    posts: [{ id: 'p1', x: -4, z: -3, h: 4.0, type: 'post', work: 'cloth' }],
    fence: { sides: ['north', 'south', 'east', 'west'] },
    wind: {},
  };
}

// ---------------------------------------------------------------------------
// REFS — how the editor addresses a placed thing
// ---------------------------------------------------------------------------
//   { kind: 'post'|'tree'|'structure', id }   an entry in that array
//   { kind: 'house'|'shed'|'shedTable'|'gnome'|'bike'|'gardenBed' }   the singleton
// A ref is plain data so it survives a rebuild; nothing holds a mesh.

const ARRAY_KIND = { post: 'posts', tree: 'trees', structure: 'structures' };

/**
 * Unique id in the namespace this kind occupies, e.g. p1, p2, t1, s1.
 * @param {object} site @param {string} kind
 */
export function nextId(site, kind) {
  const key = ARRAY_KIND[kind];
  const taken = new Set((site[key] ?? []).map((e) => e.id));
  const stem = kind === 'post' ? 'p' : kind === 'tree' ? 't' : 's';
  for (let i = 1; i < 999; i++) if (!taken.has(`${stem}${i}`)) return `${stem}${i}`;
  return `${stem}${Date.now()}`;
}

/**
 * Write one palette item into a site at (x, z). Pure-ish: mutates `site` and
 * returns the ref, touching nothing else.
 *
 * Extracted from the click handler on purpose — a.test's round-trip places its
 * yard through THIS function, so the test exercises the code the mouse runs
 * rather than a test-shaped imitation of it. A round-trip assert against a
 * separate placement path would prove nothing about the editor.
 *
 * @returns {{kind:string, id?:string}}
 */
export function placeEntry(site, item, x, z) {
  const key = ARRAY_KIND[item.kind];
  const id = key ? nextId(site, item.kind) : null;
  const entry = item.make(id, round3(x), round3(z));
  if (key) {
    site[key] ??= [];
    site[key].push(entry);
  } else {
    site[item.kind] = entry;
  }
  return key ? { kind: item.kind, id } : { kind: item.kind };
}

const refEq = (a, b) => !!a && !!b && a.kind === b.kind && (a.id ?? null) === (b.id ?? null);

/** Every scene node world.js builds for this ref (graybox AND dressed AND wreck). */
function nodeNamesFor(ref) {
  switch (ref.kind) {
    case 'post': return [`sail_post_${ref.id}`];
    case 'tree': return [`tree_${ref.id}`];
    case 'structure': return [ref.id, `${ref.id}_wrecked`];
    case 'house': return ['house_yardside', 'house_yardside_wrecked'];
    case 'shed': return ['shed_01'];
    case 'shedTable': return ['shed_table'];
    case 'gnome': return ['garden_gnome_01'];
    case 'bike': return ['bike_kid_01'];
    case 'gardenBed': return ['garden_bed'];
    default: return [];
  }
}

// ---------------------------------------------------------------------------

/**
 * Build the editor.
 * @param {{canvas:HTMLCanvasElement, side:HTMLElement, toolbar:HTMLElement,
 *          readout:HTMLElement, hint:HTMLElement, site?:string}} opts
 */
export async function createEditor(opts) {
  const { canvas, side, toolbar, readout, hint } = opts;

  const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;

  const scene = new THREE.Scene();
  const camera = new THREE.PerspectiveCamera(52, 1, 0.1, 400);

  /** C's gizmos live here. Cleared on every rebuild — see `rebuild()`. */
  const overlay = new THREE.Group();
  overlay.name = 'editor_overlay';
  scene.add(overlay);

  /** The editor's OWN pick handles + selection ring. Never exported, never dressed. */
  const handles = new THREE.Group();
  handles.name = 'editor_handles';
  scene.add(handles);

  // The wind the editor renders with is the calm stub, deliberately: gate 1 is
  // geometry, and a yard that only looks right in a gale is a yard nobody can
  // place a post in. C's overlay samples the REAL site wind for the storm read
  // (gate 2.2) — that's their seam, and it does not become this one.
  const wind = createStubWind({ calm: true });

  let site = emptyTemplate();
  let world = null;
  let selection = null;
  let validationCache = { ok: true, errors: [], warnings: [], bounds: [] };
  let rebuilding = false;
  let rebuildMs = 0;
  let lastStatus = 'template';

  const listeners = new Map();
  const emit = (evt, payload) => {
    for (const fn of listeners.get(evt) ?? []) {
      try { fn(payload); } catch (err) { console.error(`[editor] listener for '${evt}' threw:`, err); }
    }
  };

  // --- panels ------------------------------------------------------------
  const panels = new Map();
  /**
   * Idempotent per id — see editor.html's seam contract. `order` is what keeps
   * B's card and C's wind block in a fixed place in the rail no matter which
   * lane's script happens to run first.
   */
  function mountPanel({ id, title, order = 100, collapsed = false }) {
    if (panels.has(id)) {
      const p = panels.get(id);
      if (title) p.titleEl.firstChild.textContent = title;
      return p;
    }
    const root = document.createElement('section');
    root.className = 'ed-panel';
    root.dataset.panel = id;
    root.dataset.order = String(order);
    if (collapsed) root.classList.add('collapsed');

    const titleEl = document.createElement('h2');
    titleEl.className = 'ed-panel-title';
    titleEl.append(document.createTextNode(title ?? id));
    titleEl.addEventListener('click', () => root.classList.toggle('collapsed'));

    const body = document.createElement('div');
    body.className = 'ed-panel-body';

    root.append(titleEl, body);
    side.append(root);
    // Re-sort by declared order so a late mount lands in its reserved slot.
    [...side.children]
      .sort((a, b) => Number(a.dataset.order) - Number(b.dataset.order))
      .forEach((el) => side.append(el));

    const panel = { root, body, titleEl, id };
    panels.set(id, panel);
    return panel;
  }

  // --- validation --------------------------------------------------------

  /**
   * Anchors that name a GLB node the asset doesn't actually have.
   *
   * `validateSite` can't catch this — it never opens the model. But an anchor
   * whose node is missing is silently NOT adopted: it keeps its graybox
   * position and `ratingHint` defaults to 1, so a carport beam rated 0.22
   * becomes honest steel and the site's whole trap evaporates while every test
   * stays green. The tell is `collateral`: `adoptAnchor` always sets it (to
   * null when the node carries none), and nothing else ever does.
   */
  function adoptionWarnings() {
    if (!world) return [];
    const out = [];
    const declared = [
      ...(site.house?.anchors ?? []).map((a) => [a, site.house.model]),
      ...(site.trees ?? []).flatMap((t) => (t.anchors ?? []).map((a) => [a, t.model])),
      ...(site.structures ?? []).flatMap((s) => (s.anchors ?? []).map((a) => [a, s.model])),
    ];
    for (const [a, model] of declared) {
      if (!a.node) continue;
      const live = world.anchors.find((x) => x.id === a.id);
      if (!live) { out.push(`anchor ${a.id}: declared but not built`); continue; }
      if (!Object.hasOwn(live, 'collateral')) {
        out.push(`anchor ${a.id}: node "${a.node}" not found in ${model} — it is sitting at its `
               + 'graybox position with rating_hint 1.0, so any trap on it is not real');
      }
      // E's `tie_off: false` (SPRINT14 gate 3.1). A door step, a bench top, a
      // broom grip and a lighting hint are not steel, but `adoptAnchor` reads
      // `rating_hint ?? 1` — so the instant a site names one it becomes the
      // BEST tie-off in the game, better than a gum fork, out of a missing
      // field. The palette doesn't offer them, but a hand-edited site or an
      // imported yard can still name one, and this is the only place that can
      // see it: `validateSite` never opens the GLB.
      if (live.tieOff === false) {
        out.push(`anchor ${a.id}: node "${a.node}" in ${model} is NOT a tie-off `
               + '(tie_off: false — it is a step/benchtop/grip, not steel). Rigging to it would '
               + 'hand you the strongest anchor in the yard by accident. Remove it.');
      }
    }
    return out;
  }

  /** Runs on EVERY edit. Errors render in the panel, never the console. */
  function revalidate() {
    const errors = [];
    try {
      validateSite(structuredClone(site), site.id ?? '?');
    } catch (err) {
      // validateSite throws one message with the reasons on indented lines.
      const lines = String(err.message).split('\n').map((s) => s.trim()).filter(Boolean);
      errors.push(...lines.slice(1));
      if (!errors.length) errors.push(String(err.message));
    }
    validationCache = {
      ok: errors.length === 0,
      errors,
      warnings: adoptionWarnings(),
      // bounds ride in the cache so the header, the panel and the export all
      // read ONE measurement — three surfaces disagreeing about where the
      // fence is would be the two-harness disease in miniature.
      bounds: boundsFaults(site),
    };
    return validationCache;
  }

  // --- world rebuild -----------------------------------------------------

  /**
   * Rebuild the yard from the live site object, through the game's own path.
   *
   * Guarded per-call: an invalid site (which the editor produces constantly and
   * on purpose while you're mid-build) must leave the LAST GOOD world standing
   * rather than blanking the viewport — you cannot fix a yard you can't see.
   */
  async function rebuild() {
    revalidate();
    if (!validationCache.ok && world) {
      // Keep the standing yard; the panel already says why.
      lastStatus = 'invalid — showing last valid yard';
      rebuildHandles();
      renderPanels();
      emit('change', { site });
      return world;
    }
    const t0 = performance.now();
    rebuilding = true;
    try {
      const next = createWorld(scene, { wind, site: structuredClone(site) });
      if (world) world.dispose();
      world = next;
      await world.dress();
      lastStatus = 'ok';
    } catch (err) {
      lastStatus = `build failed: ${err.message}`;
      validationCache.errors.push(`createWorld: ${err.message}`);
      validationCache.ok = false;
    } finally {
      rebuilding = false;
      rebuildMs = performance.now() - t0;
    }
    // C's gizmos are rebuilt from the site, so the editor owns clearing them —
    // a stale arrow over a moved venturi is the wind-authoring cousin of the
    // phantom sail, and one lane clearing is better than two lanes remembering.
    overlay.clear();
    revalidate();
    rebuildHandles();
    renderPanels();
    emit('rebuild', { world, site });
    emit('change', { site });
    return world;
  }

  // --- pick handles ------------------------------------------------------

  const HANDLE_MAT = new THREE.MeshBasicMaterial({ visible: false });
  const RING_MAT = new THREE.MeshBasicMaterial({ color: 0x7ee0ff, transparent: true, opacity: 0.85 });
  const RING_GEO = new THREE.RingGeometry(0.72, 0.92, 28).rotateX(-Math.PI / 2);
  const DOT_GEO = new THREE.RingGeometry(0.34, 0.44, 20).rotateX(-Math.PI / 2);
  const DOT_MAT = new THREE.MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.28 });
  // The yard rectangle (gate 3.2). Amber, not red: out-of-bounds is a warning
  // state and the line itself is neutral information.
  const BOUNDS_MAT = new THREE.LineBasicMaterial({ color: 0xd9a94a, transparent: true, opacity: 0.55 });

  /**
   * The editor picks its OWN invisible handles rather than the dressed meshes.
   *
   * Two reasons, both learned elsewhere in this repo: raycasting a GLB gives
   * you a leaf mesh and no way back to the site entry that spawned it, and
   * `world.js`'s node naming is world.js's business — coupling the editor's
   * picking to it would make a rename in the loader a silent break in the
   * editor. This is the same trick rigging.js uses for anchor markers
   * (`userData.anchorId`), which is precedent, not novelty.
   */
  function rebuildHandles() {
    handles.clear();
    // The yard rectangle, drawn as a line at ankle height — the fence only
    // draws on sides the site enables, so "where do the bounds end" was
    // invisible on open sides and objects placed past them silently (D's
    // gate-3.2). Rebuilt with the handles because yard w/d are editable.
    {
      const hw = (site.yard?.width ?? 0) / 2, hd = (site.yard?.depth ?? 0) / 2;
      if (hw > 0 && hd > 0) {
        const pts = [[-hw, -hd], [hw, -hd], [hw, hd], [-hw, hd]].map(
          ([x, z]) => new THREE.Vector3(x, heightAt(x, z) + 0.06, z),
        );
        const loop = new THREE.LineLoop(
          new THREE.BufferGeometry().setFromPoints(pts),
          BOUNDS_MAT,
        );
        loop.renderOrder = 1;
        handles.add(loop);
      }
    }
    for (const ref of allRefs()) {
      const e = entryFor(ref);
      if (!e) continue;
      const y = heightAt(e.x, e.z);
      const box = new THREE.Mesh(new THREE.CylinderGeometry(0.85, 0.85, 3.4, 10), HANDLE_MAT);
      box.position.set(e.x, y + 1.7, e.z);
      box.userData.ref = ref;
      handles.add(box);

      const dot = new THREE.Mesh(DOT_GEO, DOT_MAT);
      dot.position.set(e.x, y + 0.04, e.z);
      dot.renderOrder = 2;
      handles.add(dot);

      if (refEq(ref, selection)) {
        const ring = new THREE.Mesh(RING_GEO, RING_MAT);
        ring.position.set(e.x, y + 0.05, e.z);
        ring.renderOrder = 3;
        handles.add(ring);
      }
    }
  }

  function allRefs() {
    const out = [];
    for (const [kind, key] of Object.entries(ARRAY_KIND)) {
      for (const e of site[key] ?? []) out.push({ kind, id: e.id });
    }
    for (const kind of ['house', 'shed', 'shedTable', 'gnome', 'bike', 'gardenBed']) {
      if (site[kind]) out.push({ kind });
    }
    return out;
  }

  function entryFor(ref) {
    if (!ref) return null;
    const key = ARRAY_KIND[ref.kind];
    if (key) return (site[key] ?? []).find((e) => e.id === ref.id) ?? null;
    return site[ref.kind] ?? null;
  }

  function labelFor(ref) {
    const e = entryFor(ref);
    if (!e) return ref.kind;
    if (ARRAY_KIND[ref.kind]) return `${ref.kind} ${ref.id}`;
    return ref.kind;
  }

  // --- placement / move / delete ----------------------------------------

  function place(paletteItem, x, z) {
    selection = placeEntry(site, paletteItem, x, z);
    emit('select', { ref: selection });
    return rebuild();
  }

  /**
   * Move without a rebuild — drag feedback. The site entry is written and the
   * entry's scene nodes TRANSLATE by the delta.
   *
   * Translation, not assignment, on all three axes: world.js places a dressed
   * GLB at `(x, heightAt(x,z), z)` but leaves the graybox house group at the
   * origin with its offsets in the children, so assigning y would teleport the
   * graybox and only the graybox. A delta is correct for both, which is the
   * point of never assuming another module's local frame.
   */
  function moveTo(ref, x, z) {
    const e = entryFor(ref);
    if (!e) return;
    const nx = round3(x), nz = round3(z);
    const dx = nx - e.x;
    const dz = nz - e.z;
    const dy = heightAt(nx, nz) - heightAt(e.x, e.z);
    e.x = nx;
    e.z = nz;
    for (const name of nodeNamesFor(ref)) {
      const node = world?.root?.getObjectByName(name);
      if (node) node.position.set(node.position.x + dx, node.position.y + dy, node.position.z + dz);
    }
    rebuildHandles();
  }

  function remove(ref) {
    if (!ref || UNDELETABLE.has(ref.kind)) return false;
    const key = ARRAY_KIND[ref.kind];
    if (key) {
      const i = (site[key] ?? []).findIndex((e) => e.id === ref.id);
      if (i < 0) return false;
      site[key].splice(i, 1);
      if (!site[key].length) delete site[key];
    } else {
      if (!site[ref.kind]) return false;
      delete site[ref.kind];
    }
    selection = null;
    emit('select', { ref: null });
    rebuild();
    return true;
  }

  // --- camera: orbit / pan / dolly --------------------------------------

  const cam = { target: new THREE.Vector3(0, 1, 0), dist: 26, yaw: 0.6, pitch: 0.62 };
  function applyCamera() {
    cam.pitch = Math.max(0.06, Math.min(1.45, cam.pitch));
    cam.dist = Math.max(4, Math.min(120, cam.dist));
    const r = cam.dist * Math.cos(cam.pitch);
    camera.position.set(
      cam.target.x + r * Math.sin(cam.yaw),
      cam.target.y + cam.dist * Math.sin(cam.pitch),
      cam.target.z + r * Math.cos(cam.yaw),
    );
    camera.lookAt(cam.target);
  }

  // --- pointer -----------------------------------------------------------

  const ray = new THREE.Raycaster();
  const ndc = new THREE.Vector2();
  const GROUND_PLANE = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
  const _hit = new THREE.Vector3();

  function setNdc(ev) {
    const r = canvas.getBoundingClientRect();
    ndc.set(((ev.clientX - r.left) / r.width) * 2 - 1, -((ev.clientY - r.top) / r.height) * 2 + 1);
    ray.setFromCamera(ndc, camera);
  }

  /**
   * Where on the ground the cursor is. Two steps on purpose: the flat y=0 plane
   * gives the xz, then `heightAt` gives the true ground y there. Raycasting the
   * terrain mesh instead would be 4800 triangles to learn something world.js
   * already answers in closed form (its own comment says so).
   */
  function raycastGround(ev) {
    setNdc(ev);
    if (!ray.ray.intersectPlane(GROUND_PLANE, _hit)) return null;
    return { x: _hit.x, y: heightAt(_hit.x, _hit.z), z: _hit.z };
  }

  /**
   * How near the ground point counts as "you meant that one", in metres. A
   * shade post is ~0.1 m of actual geometry, so picking has to be generous or
   * authoring becomes a game of hunting pixels.
   */
  const PICK_GROUND_RADIUS = 1.3;

  /**
   * What did the user click on?
   *
   * TWO passes, and the second is not a nicety — it is the fix for a bug this
   * file shipped with for an afternoon. Pass one raycasts the invisible body
   * handles, which is right when you click an object's MIDDLE. But a handle is
   * a cylinder centred 1.7 m up, and from a raised editor camera the ray
   * through the pixel at an object's BASE has already travelled ~2 m
   * horizontally by the time it climbs to that height — so it misses the
   * cylinder completely. Clicking the foot of the carport, which is where the
   * eye goes and is the very pixel you clicked to PLACE it, selected nothing.
   *
   * Pass two asks the question the user is actually asking: of everything
   * placed, which is nearest to the patch of ground I clicked? That is
   * camera-angle independent, so it cannot rot the way the raycast did the
   * moment the pitch changed.
   *
   * Found by driving real pointer events at the page rather than by reading
   * the code: the handles were provably in the scene, at provably the right
   * positions, and the raycast still returned nothing.
   */
  function pickRef(ev) {
    setNdc(ev);
    const hits = ray.intersectObjects(handles.children, false);
    for (const h of hits) if (h.object.userData.ref) return h.object.userData.ref;

    const g = raycastGround(ev);
    if (!g) return null;
    let best = null;
    let bestD = PICK_GROUND_RADIUS;
    for (const ref of allRefs()) {
      const e = entryFor(ref);
      if (!e) continue;
      const d = Math.hypot(e.x - g.x, e.z - g.z);
      if (d < bestD) { bestD = d; best = ref; }
    }
    return best;
  }

  /**
   * Pointer tools. C's venturi placement is one of these: register it, call
   * `setTool('venturi')`, and the editor's own select/drag stops competing for
   * the same clicks instead of two lanes both listening on the canvas.
   *
   * spec: { id, label, cursor?, onPointerDown(ev, ground), onPointerMove, onPointerUp }
   * Return `true` from a handler to consume the event.
   */
  const tools = new Map();
  let activeTool = null;
  function registerTool(spec) {
    tools.set(spec.id, spec);
    renderToolbar();
    return spec;
  }
  function setTool(id) {
    activeTool = id ? (tools.get(id) ?? null) : null;
    armed = null;
    canvas.style.cursor = activeTool?.cursor ?? 'default';
    renderToolbar();
    renderPanels();
  }

  /** The palette item waiting for a click, if any. */
  let armed = null;

  let drag = null;
  canvas.addEventListener('pointerdown', (ev) => {
    canvas.setPointerCapture(ev.pointerId);
    const ground = raycastGround(ev);
    if (activeTool?.onPointerDown?.(ev, ground)) return;

    if (ev.button === 2 || ev.shiftKey) { drag = { mode: 'pan', x: ev.clientX, y: ev.clientY }; return; }
    if (ev.button !== 0) return;

    if (armed && ground) {
      const item = armed;
      armed = null;
      renderPanels();
      place(item, ground.x, ground.z);
      return;
    }
    const ref = pickRef(ev);
    if (ref) {
      selection = ref;
      emit('select', { ref });
      rebuildHandles();
      renderPanels();
      drag = { mode: 'move', ref, moved: false };
      return;
    }
    selection = null;
    emit('select', { ref: null });
    rebuildHandles();
    renderPanels();
    drag = { mode: 'orbit', x: ev.clientX, y: ev.clientY };
  });

  canvas.addEventListener('pointermove', (ev) => {
    const ground = raycastGround(ev);
    if (activeTool?.onPointerMove?.(ev, ground)) return;
    if (!drag) return;
    if (drag.mode === 'orbit') {
      cam.yaw -= (ev.clientX - drag.x) * 0.006;
      cam.pitch += (ev.clientY - drag.y) * 0.005;
      drag.x = ev.clientX; drag.y = ev.clientY;
      applyCamera();
    } else if (drag.mode === 'pan') {
      const k = cam.dist * 0.0016;
      const dx = (ev.clientX - drag.x) * k, dy = (ev.clientY - drag.y) * k;
      cam.target.x += -Math.cos(cam.yaw) * dx + Math.sin(cam.yaw) * dy;
      cam.target.z += Math.sin(cam.yaw) * dx + Math.cos(cam.yaw) * dy;
      drag.x = ev.clientX; drag.y = ev.clientY;
      applyCamera();
    } else if (drag.mode === 'move' && ground) {
      drag.moved = true;
      moveTo(drag.ref, ground.x, ground.z);
      renderPanels();
    }
  });

  canvas.addEventListener('pointerup', (ev) => {
    canvas.releasePointerCapture?.(ev.pointerId);
    const ground = raycastGround(ev);
    if (activeTool?.onPointerUp?.(ev, ground)) { drag = null; return; }
    const wasMove = drag?.mode === 'move' && drag.moved;
    drag = null;
    // The rebuild is on DROP, not on every move: anchors have to come out of
    // createWorld+dress to be honest, and that is a drop-cost, not a drag-cost.
    if (wasMove) rebuild();
  });

  canvas.addEventListener('contextmenu', (ev) => ev.preventDefault());
  canvas.addEventListener('wheel', (ev) => {
    ev.preventDefault();
    cam.dist *= Math.exp(ev.deltaY * 0.0012);
    applyCamera();
  }, { passive: false });

  addEventListener('keydown', (ev) => {
    if (/^(INPUT|TEXTAREA|SELECT)$/.test(ev.target?.tagName ?? '')) return;
    if ((ev.key === 'Delete' || ev.key === 'Backspace') && selection) {
      ev.preventDefault();
      remove(selection);
    }
    if (ev.key === 'Escape') { armed = null; setTool(null); }
    // Keyboard zoom (D's gate-3 friction: wheel-only). Same curve as the
    // wheel handler — one Exp step ≈ three notches — so the two agree.
    if (ev.key === '+' || ev.key === '=') { cam.dist *= Math.exp(-0.12); applyCamera(); }
    if (ev.key === '-' || ev.key === '_') { cam.dist *= Math.exp(0.12); applyCamera(); }
  });

  // =======================================================================
  // Panels
  // =======================================================================

  const pSite = mountPanel({ id: 'site', title: 'SITE', order: 10 });
  const pPalette = mountPanel({ id: 'palette', title: 'PLACE', order: 20 });
  const pInspector = mountPanel({ id: 'inspector', title: 'SELECTED', order: 30 });
  const pValidate = mountPanel({ id: 'validate', title: 'VALIDATION', order: 80 });
  const pExport = mountPanel({ id: 'export', title: 'EXPORT', order: 90 });

  const el = (tag, cls, text) => {
    const n = document.createElement(tag);
    if (cls) n.className = cls;
    if (text != null) n.textContent = text;
    return n;
  };
  function row(label, control) {
    const r = el('div', 'ed-row');
    r.append(el('span', 'ed-label', label), control);
    return r;
  }
  function numInput(get, set, step = 0.1) {
    const i = el('input', 'ed-num');
    i.type = 'number'; i.step = String(step); i.value = String(get() ?? 0);
    // Commit on Enter AND change (D's gate-3 friction: "Enter does nothing,
    // and a house sat on the garden bed while the panel read the new value").
    // Idempotent on purpose — Enter then blur fires both paths, and a no-op
    // commit must not pay a rebuild.
    const commit = () => {
      const v = Number(i.value);
      if (!Number.isFinite(v) || round3(v) === get()) return;
      set(round3(v));
      rebuild();
    };
    i.addEventListener('change', commit);
    i.addEventListener('keydown', (ev) => {
      if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
    });
    return i;
  }
  function textInput(get, set) {
    const i = el('input', 'ed-text');
    i.type = 'text'; i.value = get() ?? '';
    const commit = () => {
      if (i.value === (get() ?? '')) return;   // same idempotence as numInput
      set(i.value);
      markDirty();
    };
    i.addEventListener('change', commit);
    i.addEventListener('keydown', (ev) => {
      if (ev.key === 'Enter') { ev.preventDefault(); commit(); }
    });
    return i;
  }

  /**
   * `_design` / `_why` are edited as text and STORED as a string array, because
   * that is how every shipped site stores them — JSON has no comments, so the
   * repo's convention is one array element per line. Making the field visible
   * is gate 1.3's actual requirement: the levels carry their design notes, and
   * a note you have to remember to add is a note that doesn't get added.
   */
  function notesInput(key) {
    const t = el('textarea', 'ed-text');
    t.rows = 5;
    t.value = Array.isArray(site[key]) ? site[key].join('\n') : (site[key] ?? '');
    t.addEventListener('change', () => {
      const lines = t.value.replace(/\s+$/, '').split('\n');
      if (lines.length === 1 && !lines[0]) delete site[key];
      else site[key] = lines;
      markDirty();
    });
    return t;
  }

  function renderSitePanel() {
    const b = pSite.body;
    b.replaceChildren();
    b.append(row('id', textInput(() => site.id, (v) => { site.id = v; })));
    b.append(row('name', textInput(() => site.name, (v) => { site.name = v; })));

    const blurb = el('textarea', 'ed-text');
    blurb.rows = 3;
    blurb.value = site.blurb ?? '';
    blurb.addEventListener('change', () => { site.blurb = blurb.value; markDirty(); });
    b.append(row('blurb', blurb));
    b.append(el('p', 'ed-note',
      'The blurb carries real design load — night 3\'s "plenty to tie off to" is the '
      + 'carport trap\'s cover story.'));

    b.append(row('yard w', numInput(() => site.yard.width, (v) => { site.yard.width = v; }, 0.5)));
    b.append(row('yard d', numInput(() => site.yard.depth, (v) => { site.yard.depth = v; }, 0.5)));
    b.append(row('sun elev', numInput(() => site.sun.elevationDeg, (v) => { site.sun.elevationDeg = v; }, 1)));
    b.append(row('sun azim', numInput(() => site.sun.azimuthDeg, (v) => { site.sun.azimuthDeg = v; }, 1)));

    const sides = el('div', 'ed-row');
    sides.append(el('span', 'ed-label', 'fence'));
    for (const s of ['north', 'south', 'east', 'west']) {
      const btn = el('button', 'ed-btn', s[0].toUpperCase());
      const on = (site.fence?.sides ?? []).includes(s);
      if (on) btn.classList.add('on');
      btn.title = s;
      btn.addEventListener('click', () => {
        site.fence ??= { sides: [] };
        const list = new Set(site.fence.sides ?? []);
        if (list.has(s)) list.delete(s); else list.add(s);
        // Fixed order, not click order: a fence list that reorders itself is a
        // diff that looks like a change and isn't.
        site.fence.sides = ['north', 'south', 'east', 'west'].filter((k) => list.has(k));
        rebuild();
      });
      sides.append(btn);
    }
    b.append(sides);

    // Gate 3.4's ruling, said where an author would look for the field: there
    // is deliberately NO separation input. A `separation` block is a MEASURED
    // promise (gardenfly's held-vs-bare, through the real chain), and a
    // hand-typed one would be a wish wearing a pin's clothes. SCORE IT
    // measures it; the pin lands at integration (MANUAL, site cookbook §5).
    b.append(el('p', 'ed-note',
      'No separation field, on purpose: separation is a measured pin, not an '
      + 'authored wish. Run SCORE IT and pin the measured number at integration.'));

    b.append(el('p', 'ed-label', '_design'), notesInput('_design'));
    b.append(el('p', 'ed-note', 'Why this yard exists and what you measured. Ships in the JSON.'));
    b.append(el('p', 'ed-label', '_why'), notesInput('_why'));

    const load = el('div', 'ed-row');
    load.append(el('span', 'ed-label', 'load'));
    for (const s of ['backyard_01', 'site_02_corner_block']) {
      const btn = el('button', 'ed-btn', s === 'backyard_01' ? 'backyard' : 'corner');
      btn.addEventListener('click', async () => {
        try {
          const def = await loadSite(s);
          await setSite(structuredClone(def));
        } catch (err) {
          validationCache.errors.push(`load ${s}: ${err.message}`);
          renderPanels();
        }
      });
      load.append(btn);
    }
    const blank = el('button', 'ed-btn', 'template');
    blank.addEventListener('click', () => setSite(emptyTemplate()));
    load.append(blank);
    b.append(load);
  }

  /**
   * The palette can only offer what the CHECKED enum will accept.
   *
   * `requires` names the anchor types an item writes. If contracts.js doesn't
   * carry one yet, the item is hidden rather than offered — placing it would
   * produce a site `validateSite` rejects, i.e. the editor handing you a yard
   * the game can't boot, which is the one thing gate 1 exists to prevent.
   *
   * It also makes the cross-lane seam self-healing: E's swing set types
   * `swing_frame`, and it appears in this palette the moment their widening of
   * ANCHOR_TYPE lands in the merge — no follow-up commit, and no window where
   * the button exists and the enum doesn't.
   */
  const availablePalette = () =>
    PALETTE.filter((item) => (item.requires ?? []).every((ty) => ANCHOR_TYPE.includes(ty)));

  function renderPalettePanel() {
    const b = pPalette.body;
    b.replaceChildren();
    for (const item of availablePalette()) {
      const exists = item.singleton && site[item.kind];
      const btn = el('button', 'ed-btn', item.label);
      btn.style.width = '100%';
      btn.style.textAlign = 'left';
      btn.style.marginBottom = '4px';
      btn.title = item.hint;
      if (armed === item) btn.classList.add('on');
      if (exists) {
        btn.disabled = true;
        btn.textContent = `${item.label}  (placed)`;
      }
      btn.addEventListener('click', () => {
        armed = armed === item ? null : item;
        setToolNone();
        renderPanels();
      });
      b.append(btn);
    }
    b.append(el('p', 'ed-note', armed
      ? `Click the ground to place: ${armed.label}. ${armed.hint}. ESC cancels.`
      : 'Pick one, then click the ground. Drag a placed thing to move it; '
        + 'Delete removes it. Right-drag or shift-drag pans, wheel zooms.'));
  }

  function setToolNone() { if (activeTool) setTool(null); }

  function renderInspectorPanel() {
    const b = pInspector.body;
    b.replaceChildren();
    const e = entryFor(selection);
    if (!e) {
      b.append(el('p', 'ed-note', 'Nothing selected. Click a marker in the yard.'));
      const list = el('ul', 'ed-list');
      for (const ref of allRefs()) {
        const li = el('li');
        li.append(el('span', null, labelFor(ref)));
        const ent = entryFor(ref);
        li.append(el('span', 'ed-kv', `${ent.x?.toFixed?.(1) ?? '—'}, ${ent.z?.toFixed?.(1) ?? '—'}`));
        li.addEventListener('click', () => {
          selection = ref;
          emit('select', { ref });
          rebuildHandles();
          renderPanels();
        });
        list.append(li);
      }
      b.append(list);
      return;
    }
    const head = el('div', 'ed-card-head');
    head.append(el('span', null, labelFor(selection)));
    if (e.model) head.append(el('span', 'ed-tag', e.model));
    b.append(head);

    b.append(row('x', numInput(() => e.x, (v) => { e.x = v; })));
    b.append(row('z', numInput(() => e.z, (v) => { e.z = v; })));
    if (Object.hasOwn(e, 'rotYDeg')) b.append(row('rotY°', numInput(() => e.rotYDeg, (v) => { e.rotYDeg = v; }, 5)));
    if (Object.hasOwn(e, 'h')) b.append(row('height', numInput(() => e.h, (v) => { e.h = v; }, 0.25)));
    if (Object.hasOwn(e, 'trunkH')) b.append(row('trunkH', numInput(() => e.trunkH, (v) => { e.trunkH = v; }, 0.1)));
    if (Object.hasOwn(e, 'anchorY')) b.append(row('anchorY', numInput(() => e.anchorY, (v) => { e.anchorY = v; }, 0.1)));
    if (Object.hasOwn(e, 'phase')) b.append(row('phase', numInput(() => e.phase, (v) => { e.phase = v; }, 0.1)));
    if (selection.kind === 'gardenBed') {
      b.append(row('bed w', numInput(() => e.w, (v) => { e.w = v; }, 0.25)));
      b.append(row('bed d', numInput(() => e.d, (v) => { e.d = v; }, 0.25)));
    }
    if (Object.hasOwn(e, 'collateralValue')) {
      b.append(row('collateral $', numInput(() => e.collateralValue, (v) => { e.collateralValue = v; }, 5)));
    }

    if (e.anchors?.length) {
      b.append(el('p', 'ed-note', 'anchors (live, from the dressed world):'));
      const list = el('ul', 'ed-list');
      for (const a of e.anchors) {
        const live = world?.anchors.find((x) => x.id === a.id);
        const li = el('li');
        li.append(el('span', null, `${a.id} · ${a.type} · ${a.work}`));
        const rh = live?.ratingHint;
        const tag = el('span', 'ed-tag', live ? `hint ${(rh ?? 1).toFixed(2)}` : 'not built');
        if (!live || !Object.hasOwn(live, 'collateral')) tag.classList.add('ed-err');
        else if ((rh ?? 1) < 0.5) tag.classList.add('ed-warn');
        li.append(tag);
        list.append(li);
      }
      b.append(list);
    }

    if (!UNDELETABLE.has(selection.kind)) {
      const del = el('button', 'ed-btn danger', `delete ${labelFor(selection)}`);
      del.style.marginTop = '8px';
      del.addEventListener('click', () => remove(selection));
      b.append(del);
    }
  }

  function renderValidationPanel() {
    const b = pValidate.body;
    b.replaceChildren();
    const v = validationCache;
    const head = el('div', 'ed-card-head');
    head.append(el('span', v.ok ? 'ed-ok' : 'ed-err', v.ok ? 'VALID — the game can boot this' : `INVALID — ${v.errors.length} problem(s)`));
    b.append(head);

    if (!v.ok) {
      const list = el('ul', 'ed-list');
      for (const e of v.errors) list.append(el('li', 'ed-err', e));
      b.append(list);
      if (v.errors.some((e) => e.includes('no anchors at all'))) {
        b.append(el('p', 'ed-note',
          'A yard with nothing to tie off to is not a level. Place a Post (or a tree, '
          + 'or the house) and this goes green.'));
      }
    }
    if (v.warnings.length) {
      b.append(el('p', 'ed-warn', `${v.warnings.length} asset warning(s) — validateSite cannot see these:`));
      const list = el('ul', 'ed-list');
      for (const w of v.warnings) list.append(el('li', 'ed-warn', w));
      b.append(list);
    }
    if (v.bounds?.length) {
      b.append(el('p', 'ed-warn',
        `${v.bounds.length} outside the yard — the game boots this, but is it what you meant?`));
      const list = el('ul', 'ed-list');
      for (const w of v.bounds) list.append(el('li', 'ed-warn', w));
      b.append(list);
    }
    if (v.ok && !v.warnings.length && !v.bounds?.length) {
      b.append(el('p', 'ed-note',
        'Validation is structural only: it says the game can BOOT this yard, not that '
        + 'the yard is a game. That question is SCORE IT.'));
    }
  }

  function renderExportPanel() {
    const b = pExport.body;
    b.replaceChildren();

    const bar = el('div', 'ed-row');
    const copy = el('button', 'ed-btn primary', 'copy JSON');
    copy.addEventListener('click', async () => {
      const text = exportJSON();
      try {
        await navigator.clipboard.writeText(text);
        copy.textContent = 'copied ✓';
      } catch {
        copy.textContent = 'clipboard blocked — use download';
      }
      setTimeout(() => { copy.textContent = 'copy JSON'; }, 1800);
    });
    const dl = el('button', 'ed-btn', 'download');
    dl.addEventListener('click', () => {
      const blob = new Blob([exportJSON()], { type: 'application/json' });
      const a = document.createElement('a');
      a.href = URL.createObjectURL(blob);
      a.download = `${site.id || 'site_new'}.json`;
      a.click();
      setTimeout(() => URL.revokeObjectURL(a.href), 1000);
    });
    bar.append(copy, dl);
    b.append(bar);

    if (!validationCache.ok) {
      b.append(el('p', 'ed-err',
        'This yard does NOT validate. It will still export — you should not lose work — '
        + 'but under a loud "_INVALID" key listing every problem, so it cannot land in '
        + 'data/sites/ looking clean.'));
    }

    // Gate 1.6 — export is not shipping, and the editor has to say so, because
    // the one thing you cannot do from this page is the thing that makes a yard
    // playable. docs/MANUAL.md, "authoring a site", step 6.
    const card = el('div', 'ed-card');
    card.append(el('div', 'ed-card-head', 'EXPORT ≠ SHIPPED'));
    const ol = document.createElement('ol');
    ol.className = 'ed-note';
    ol.style.paddingLeft = '18px';
    ol.style.margin = '0';
    for (const step of [
      `Save the JSON as web/world/data/sites/${site.id || 'site_new'}.json.`,
      'A site becomes PLAYABLE only when a week.js NIGHTS entry names it — client, '
      + 'addr, brief and storm live there, not here. Until then the yard exists and '
      + 'nobody can reach it.',
      'boot({ site: \'' + (site.id || 'site_new') + '\' }) seeks the week to that night.',
      'Then run the gauntlet before calling it a level: SCORE IT here, then '
      + 'tools/site_audit, gardenfly, storm_envelope — and a cold playthrough by '
      + 'someone who did not author it.',
    ]) {
      const li = document.createElement('li');
      li.textContent = step;
      ol.append(li);
    }
    card.append(ol);
    b.append(card);

    const pre = el('pre', 'ed-note');
    pre.style.cssText = 'max-height:220px;overflow:auto;background:#0e1418;padding:8px;border-radius:4px;margin:8px 0 0;white-space:pre;';
    pre.textContent = exportJSON();
    b.append(pre);
  }

  function renderToolbar() {
    toolbar.replaceChildren();
    for (const spec of tools.values()) {
      const btn = el('button', 'ed-btn', spec.label ?? spec.id);
      if (activeTool === spec) btn.classList.add('on');
      btn.addEventListener('click', () => setTool(activeTool === spec ? null : spec.id));
      toolbar.append(btn);
    }
  }

  let renderQueued = false;
  function renderPanels() {
    if (renderQueued) return;
    renderQueued = true;
    queueMicrotask(() => {
      renderQueued = false;
      renderSitePanel();
      renderPalettePanel();
      renderInspectorPanel();
      renderValidationPanel();
      renderExportPanel();
    });
  }

  function markDirty() {
    revalidate();
    rebuildHandles();
    renderPanels();
    emit('change', { site });
  }

  function exportJSON() { return exportSiteJSON(site, validationCache); }

  async function setSite(next) {
    site = next;
    selection = null;
    emit('siteload', { site });
    await rebuild();
    // The camera frames whatever yard just arrived rather than whatever yard
    // the last one was — a 24x16 corner block viewed from a 30x20 framing reads
    // as a mistake you then chase in the data.
    cam.target.set(0, 1, 0);
    cam.dist = Math.max(site.yard.width, site.yard.depth) * 1.15;
    applyCamera();
    return world;
  }

  // --- the loop ----------------------------------------------------------

  function resize() {
    const w = canvas.clientWidth || 1;
    const h = canvas.clientHeight || 1;
    renderer.setSize(w, h, false);
    camera.aspect = w / Math.max(1, h);
    camera.updateProjectionMatrix();
  }
  addEventListener('resize', resize);

  let t = 0;
  renderer.setAnimationLoop(() => {
    t += 1 / 60;
    // The editor is a TOOL: this loop is for looking, not for simulating, so it
    // does not pretend to be the game's fixed-dt chain. Nothing here feeds a
    // number anyone scores.
    if (world && !rebuilding) world.update(1 / 60, t);
    readout.textContent =
      `${site.id}   ${site.yard.width}×${site.yard.depth} m\n`
      + `anchors ${world?.anchors.length ?? 0}   ${validationCache.ok
          ? (validationCache.bounds?.length ? `valid · ${validationCache.bounds.length} OUTSIDE BOUNDS` : 'valid')
          : 'INVALID'}\n`
      + `rebuild ${rebuildMs.toFixed(0)} ms   ${lastStatus}`;
    renderer.render(scene, camera);
  });

  hint.textContent =
    'drag empty ground = orbit · shift/right-drag = pan · wheel = zoom\n'
    + 'click a marker = select · drag it = move · Delete = remove';

  // --- the object ---------------------------------------------------------

  const api = {
    // three.js handles
    scene, renderer, camera, overlay,
    get world() { return world; },
    // site
    get site() { return site; },
    siteClone: () => canonical(structuredClone(site), '$root'),
    setSite,
    rebuild,
    markDirty,
    validation: () => ({ ...validationCache }),
    exportJSON,
    // selection
    get selection() { return selection; },
    select(ref) {
      selection = ref;
      emit('select', { ref });
      rebuildHandles();
      renderPanels();
    },
    // seams
    mountPanel,
    raycastGround,
    registerTool,
    setTool,
    get tool() { return activeTool?.id ?? null; },
    on(evt, fn) { listeners.set(evt, [...(listeners.get(evt) ?? []), fn]); return () => api.off(evt, fn); },
    off(evt, fn) { listeners.set(evt, (listeners.get(evt) ?? []).filter((f) => f !== fn)); },
    // constants other lanes need
    HEIGHT_AT: heightAt,
    PALETTE,
    ANCHOR_TYPE,
  };

  globalThis.EDITOR = api;

  resize();
  applyCamera();

  const startSite = new URLSearchParams(location.search).get('site');
  if (startSite) {
    try {
      await setSite(structuredClone(await loadSite(startSite)));
    } catch (err) {
      await setSite(emptyTemplate());
      validationCache.errors.push(`?site=${startSite}: ${err.message}`);
      renderPanels();
    }
  } else {
    await setSite(emptyTemplate());
  }

  return api;
}
