/**
 * Lane E selftests — asset sanity.
 * Lane E owns this file. Other lanes: yours is js/tests/<letter>.test.js.
 *
 * Why this exists when build_yard_assets.py already verifies: the Blender side
 * re-imports every GLB and asserts dims, tri budget and node names, but it
 * CANNOT catch an axis error. Blender exports Z-up→Y-up and imports Y-up→Z-up,
 * so a broken `export_yup` flips back on the way in and round-trips green. Only
 * a native glTF reader can prove the file is right, and this is the only one in
 * the repo. So this suite targets the failures that silently break other lanes:
 *   1. the GLB loads at all through the vendored loader;
 *   2. it's in metres with its height on +Y — a model exported in centimetres
 *      looks fine alone and absurd next to a person;
 *   3. the nodes other lanes query by name survived the export. glTF has no
 *      "empty" type, so anchors arrive as bare Object3D and are exactly what an
 *      exporter prunes.
 *
 * Standalone version with a fuller report: tools/assetcheck/.
 */

import * as THREE from '../../vendor/three.module.js';
import { assert } from '../testkit.js';

// GLTFLoader is imported DYNAMICALLY, below, and that is deliberate.
//
// Every three.js addon imports the bare specifier `three`, which only resolves
// via an <script type="importmap">. No page in this repo has one yet — index.html
// and selftest.html both import `../vendor/three.module.js` by relative path and
// so never needed it. A static import here would throw at module load, and
// selftest.html turns an un-importable lane module into a hard FAIL, which would
// redden Lane A's merge gate over a harness gap rather than a real defect.
//
// So: try it at runtime and skip with an actionable message if it's absent. The
// day the importmap lands this suite lights up on its own, no edit needed.
// Need + exact fix are logged in THREADS.md [E] — it blocks Lane D too, which
// can't load a ped without GLTFLoader/SkeletonUtils.
const LOADER_PATH = '../../vendor/addons/loaders/GLTFLoader.js';

/** Resolve off import.meta.url, not the document — survives selftest.html moving. */
const url = (a) => new URL(`../../models/${a.sub ?? ''}${a.name}_v1.glb`, import.meta.url).href;

/**
 * Height ranges rather than exact dims: this guards against unit and axis
 * regressions, not against Lane E retuning a silhouette. Exact measurements
 * live in tools/blender/asset_report.json. `nodes` are the names other lanes
 * query — changing one is a contract break and should fail here.
 */
const ASSETS = [
  { name: 'ref_capsule', h: [1.68, 1.72], nodes: ['ref_capsule_mesh', 'head_height'] },
  { name: 'tree_gum_01', h: [4.0, 9.0],
    nodes: ['trunk', 'canopy_01', 'canopy_02', 'canopy_03',
            'branch_anchor_01', 'branch_anchor_02', 'branch_anchor_03'] },
  { name: 'tree_gum_02', h: [4.0, 9.0],
    nodes: ['trunk', 'canopy_01', 'canopy_02', 'branch_anchor_01', 'branch_anchor_02'] },
  { name: 'fence_post', h: [1.8, 2.2], nodes: ['post'] },
  { name: 'fence_panel', h: [1.6, 2.0], nodes: ['palings', 'rails'] },
  { name: 'gate', h: [1.6, 2.0], nodes: ['gate_palings', 'gate_frame', 'hinges', 'hinge_axis'] },
  { name: 'house_yardside', h: [2.5, 3.5],
    nodes: ['wall', 'door', 'window', 'roof', 'fascia', 'gutter',
            'fascia_anchor_01', 'fascia_anchor_02', 'fascia_anchor_03'] },
  { name: 'shed_01', h: [1.9, 2.4], nodes: ['shell', 'roof', 'doors', 'door_anchor'] },
  { name: 'shed_table', h: [0.8, 1.0], nodes: ['table_top', 'table_frame', 'pickup_anchor'] },
  { name: 'garden_bed', h: [0.5, 1.1],
    nodes: ['bed', 'soil', 'plants_full', 'plants_tattered', 'plants_dead'] },
  { name: 'sail_post', h: [3.8, 4.2],
    nodes: ['footing', 'post', 'pad_eye', 'top_anchor', 'rake_pivot'] },
  { name: 'ladder_01', h: [2.8, 3.2], nodes: ['ladder', 'ladder_base', 'ladder_top'] },
  { name: 'shackle', h: [0.05, 0.15], nodes: ['bow', 'pin'] },
  { name: 'carabiner', h: [0.06, 0.15], nodes: ['body', 'gate'] },
  { name: 'turnbuckle', h: [0.12, 0.25], nodes: ['body', 'eye_a', 'eye_b'] },
  { name: 'tramp_01', h: [0.6, 1.0], nodes: ['mat', 'rim', 'pad', 'legs'], sub: 'debris/' },
  { name: 'wheelie_bin_01', h: [1.0, 1.2],
    nodes: ['bin_body', 'lid', 'lid_plate', 'wheels'], sub: 'debris/' },
  { name: 'washing_line_01', h: [2.0, 2.4], nodes: ['mast', 'head', 'arms'] },
  { name: 'garden_gnome_01', h: [0.33, 0.42], nodes: ['gnome'] },
  { name: 'garden_gnome_01_broken', h: [0.08, 0.20],
    nodes: ['stump', 'head', 'hat', 'shards'] },
  { name: 'fence_panel_snapped', h: [1.70, 1.90],
    nodes: ['palings', 'rails', 'debris_palings'] },
  { name: 'broom_01', h: [1.35, 1.50],
    nodes: ['handle', 'head', 'bristles', 'grip_anchor', 'poke_tip'] },
  { name: 'hail_stone_01', h: [0.012, 0.028], nodes: ['stone'] },
  { name: 'carport_01', h: [2.4, 2.8],
    nodes: ['footings', 'posts', 'beams', 'roof',
            'beam_anchor_01', 'beam_anchor_02', 'post_anchor_01', 'post_anchor_02'] },
  { name: 'carport_01_wrecked', h: [1.9, 2.45],
    nodes: ['footings', 'posts', 'beams', 'roof_down'] },
];

function sizeOf(gltf) {
  const s = new THREE.Vector3();
  new THREE.Box3().setFromObject(gltf.scene).getSize(s);
  return s;
}

/** @param {import('../testkit.js').Suite} t */
export default async function run(t) {
  let GLTFLoader;
  try {
    ({ GLTFLoader } = await import(LOADER_PATH));
  } catch (err) {
    t.skip('needs an importmap for the bare `three` specifier — see THREADS [E]. ' +
           'Assets ARE verified meanwhile: tools/assetcheck/ (16/16 green in three.js r175)');
    return;
  }

  const loader = new GLTFLoader();
  const loaded = new Map();
  const failed = new Map();

  await Promise.all(ASSETS.map(async (a) => {
    try { loaded.set(a.name, await loader.loadAsync(url(a))); }
    catch (err) { failed.set(a.name, err?.message ?? String(err)); }
  }));

  t.test('every yard GLB loads through the vendored GLTFLoader', () => {
    const lost = [...failed].map(([n, e]) => `${n} (${e})`).join('; ');
    assert(failed.size === 0, `failed to load: ${lost}`);
  });

  // The anchor of the whole scale system. If this is wrong, every judgement
  // made against the contact sheet was made against a lie.
  t.test('ref_capsule is 1.70 m tall on +Y — the scale everything is judged against', () => {
    const g = loaded.get('ref_capsule');
    assert(g, 'ref_capsule did not load');
    const s = sizeOf(g);
    assert(Math.abs(s.y - 1.70) < 0.02, `capsule is ${s.y.toFixed(3)} m on Y, want 1.70`);
    assert(s.x < 0.6 && s.z < 0.6,
      `capsule is ${s.x.toFixed(2)} x ${s.z.toFixed(2)} in plan — height is not on +Y`);
  });

  for (const a of ASSETS) {
    const gltf = loaded.get(a.name);
    if (!gltf) continue;                      // already reported by the load test
    const s = sizeOf(gltf);

    t.test(`${a.name}: metre-scale, height on +Y`, () => {
      assert(s.y >= a.h[0] && s.y <= a.h[1],
        `${a.name} stands ${s.y.toFixed(3)} m, want ${a.h[0]}–${a.h[1]} m ` +
        `(box ${s.x.toFixed(2)} x ${s.y.toFixed(2)} x ${s.z.toFixed(2)})`);
    });

    t.test(`${a.name}: named nodes survive the export`, () => {
      const names = new Set();
      gltf.scene.traverse((o) => names.add(o.name));
      const missing = a.nodes.filter((n) => !names.has(n));
      assert(missing.length === 0,
        `${a.name} lost ${missing.join(', ')} — other lanes query these by name`);
    });
  }

  // site_02's whole personality is "nowhere to tie off", and it lives in these
  // four numbers rather than in any geometry. The carport is meant to LOOK like
  // four free anchors and be the worst steel in the game — worse even than the
  // house fascia, which DESIGN.md already calls a lie. If someone quietly
  // "fixes" these ratings upward the site stops teaching anything and just
  // becomes a smaller yard, so they're pinned here with the reason attached.
  // The trap has to be scoreable or it isn't a trap, only a warning. The anchors
  // said collateral="carport" from Sprint 9 but nothing said what a carport
  // COSTS, so Lane A's aftermath had no number to reach for and the site's whole
  // lesson was unpriced. main.js already reads world.gnome.collateralValue — this
  // is the same shape in the same place.
  t.test('the carport is priced, and it is the worst bill on the site', () => {
    const c = loaded.get('carport_01')?.scene.getObjectByName('carport_01');
    assert(c, 'carport_01 root missing');
    const cost = c.userData?.collateral_value;
    assert(typeof cost === 'number', `carport carries no collateral_value (${JSON.stringify(c.userData)})`);
    const gnome = loaded.get('garden_gnome_01')?.scene
      .getObjectByName('garden_gnome_01')?.userData?.collateral_value;
    assert(cost > gnome, `carport (${cost}) must cost more than the gnome (${gnome})`);
    // Wreckage carries the same price, so scoring can read either state.
    const w = loaded.get('carport_01_wrecked')?.scene.getObjectByName('carport_01_wrecked');
    assert(w?.userData?.collateral_value === cost,
      'the wrecked carport must be priced the same as the one it used to be');
    assert(w?.userData?.broken_variant_of === 'carport_01',
      'wrecked carport must name its intact twin so A can pair the swap');
  });

  // A wreck that stands taller than the thing it was is a bug, not a wreck.
  t.test('the wrecked carport is shorter than the carport', () => {
    const a = new THREE.Box3().setFromObject(loaded.get('carport_01').scene);
    const b = new THREE.Box3().setFromObject(loaded.get('carport_01_wrecked').scene);
    const hi = a.max.y - a.min.y, lo = b.max.y - b.min.y;
    assert(lo < hi, `wreck stands ${lo.toFixed(2)} m vs the intact ${hi.toFixed(2)} m`);
    assert(Math.abs(b.min.y) < 0.05, `wreck sits at y=${b.min.y.toFixed(2)} — roof sheet through the ground?`);
  });

  t.test('carport anchors stay a trap — the corner block has no good tie-off', () => {
    const g = loaded.get('carport_01');
    assert(g, 'carport_01 did not load');
    const FASCIA = 0.35;              // house_yardside's, the previous worst
    for (const n of ['beam_anchor_01', 'beam_anchor_02']) {
      const o = g.scene.getObjectByName(n);
      assert(o, `${n} missing`);
      assert(o.userData?.rating_hint < FASCIA,
        `${n} rates ${o.userData?.rating_hint} — the carport beam must be worse than the fascia (${FASCIA})`);
      assert(o.userData?.collateral === 'carport',
        `${n} must carry collateral="carport" — taking it takes the roof`);
    }
    for (const n of ['post_anchor_01', 'post_anchor_02']) {
      const o = g.scene.getObjectByName(n);
      assert(o?.userData?.rating_hint < FASCIA,
        `${n} rates ${o?.userData?.rating_hint} — still a 90 mm post on a small pad`);
    }
  });

  // These five numbers are load-bearing for the whole repo: Lane A's winning
  // line rigs off t2, and every balance figure B and C measured assumes the
  // branch anchors sit exactly here. I reshaped the limbs in Sprint 7 for looks
  // and pinned the tips bit-for-bit to do it; this is the tripwire that keeps
  // the next bit of art from quietly invalidating a sprint of physics.
  // If it fails, the art moved an anchor — fix the art, don't touch the numbers.
  t.test('branch anchors have not moved — the balance numbers depend on them', () => {
    const EXPECT = {
      tree_gum_01: {
        branch_anchor_01: [-0.960, 3.636, -1.460],   // Blender (x,y,z) -> glTF (x,z,-y)
        branch_anchor_02: [-0.941, 4.145, 0.777],
        branch_anchor_03: [-1.382, 5.227, 0.978],
      },
      tree_gum_02: {
        branch_anchor_01: [-0.167, 2.950, 1.154],
        branch_anchor_02: [0.142, 3.722, 1.043],
      },
    };
    for (const [tree, anchors] of Object.entries(EXPECT)) {
      const g = loaded.get(tree);
      assert(g, `${tree} did not load`);
      g.scene.updateWorldMatrix(true, true);
      for (const [n, want] of Object.entries(anchors)) {
        const o = g.scene.getObjectByName(n);
        assert(o, `${tree}/${n} missing`);
        const p = new THREE.Vector3().setFromMatrixPosition(o.matrixWorld);
        const got = [p.x, p.y, p.z];
        for (let i = 0; i < 3; i++) {
          assert(Math.abs(got[i] - want[i]) < 0.005,
            `${tree}/${n} moved on ${'xyz'[i]}: ${got[i].toFixed(3)} vs ${want[i]} ` +
            `(full ${got.map((v) => v.toFixed(3)).join(', ')})`);
        }
      }
    }
  });

  // Anchors are the actual product here: Lane B pins cloth corners to them and
  // Lane A builds world.anchors from them. A surviving name isn't enough — the
  // position has to be usable.
  t.test('branch_anchor_01 resolves to a usable world position up the tree', () => {
    const g = loaded.get('tree_gum_01');
    assert(g, 'tree_gum_01 did not load');
    const anchor = g.scene.getObjectByName('branch_anchor_01');
    assert(anchor, 'branch_anchor_01 missing — glTF has no "empty", check it was not pruned');
    g.scene.updateWorldMatrix(true, true);
    const p = new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld);
    assert(p.y > 1.0 && p.y < 6.0,
      `anchor sits at y=${p.y.toFixed(2)} — want 1–6 m up the trunk`);
    assert(Number.isFinite(p.x) && Number.isFinite(p.z), 'anchor world position is not finite');
  });

  // A canopy that can't sway is just decoration, and the gust front the player
  // reads a beat before it hits the sail (world.js) is the canopy leaning. Lane A
  // rotates a `canopy` group; the blobs must swing about the TRUNK, not spin
  // about their own centres — a sphere spinning in place is invisible, which is
  // exactly the failure this catches.
  t.test('canopy sways about the trunk when Lane A rotates it', () => {
    const g = loaded.get('tree_gum_01');
    assert(g, 'tree_gum_01 did not load');
    const canopy = g.scene.getObjectByName('canopy');
    assert(canopy, 'no `canopy` group node — world.js rotates this to sway the tree');
    const blob = g.scene.getObjectByName('canopy_01');
    assert(blob, 'canopy_01 missing');

    g.scene.updateWorldMatrix(true, true);
    const before = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
    const restZ = canopy.rotation.z;
    canopy.rotation.z = restZ + 0.20;          // ≈ world.js's max lean of 0.22 rad
    canopy.updateWorldMatrix(true, true);
    const after = new THREE.Vector3().setFromMatrixPosition(blob.matrixWorld);
    canopy.rotation.z = restZ;
    canopy.updateWorldMatrix(true, true);

    const moved = before.distanceTo(after);
    assert(moved > 0.15,
      `canopy_01 moved only ${moved.toFixed(3)} m under a 0.2 rad lean — the pivot is at ` +
      'the blob centre, not the trunk top, so the tree cannot visibly sway');
  });

  // DESIGN.md: rake the post away from the load — so rake is a runtime rotation,
  // not baked. Rotating rake_pivot must carry the post and its top_anchor over
  // while the concrete footing stays level in the ground. A post whose footing
  // tips out of the dirt with it isn't raked, it's falling.
  t.test('sail_post rakes about rake_pivot with the footing left planted', () => {
    const g = loaded.get('sail_post');
    assert(g, 'sail_post did not load');
    const rake = g.scene.getObjectByName('rake_pivot');
    const anchor = g.scene.getObjectByName('top_anchor');
    const footing = g.scene.getObjectByName('footing');
    assert(rake && anchor && footing, 'sail_post needs rake_pivot, top_anchor and footing');

    const at = () => {
      g.scene.updateWorldMatrix(true, true);
      return [new THREE.Vector3().setFromMatrixPosition(anchor.matrixWorld),
              new THREE.Vector3().setFromMatrixPosition(footing.matrixWorld)];
    };
    const [a0, f0] = at();
    rake.rotation.x += (8 * Math.PI) / 180;       // Lane A rakes 8°
    const [a1, f1] = at();
    rake.rotation.x -= (8 * Math.PI) / 180;
    g.scene.updateWorldMatrix(true, true);

    const head = a0.distanceTo(a1), foot = f0.distanceTo(f1);
    assert(head > 0.3,
      `an 8° rake moved the head only ${head.toFixed(3)} m — rake_pivot has no children`);
    assert(foot < 0.01,
      `the footing moved ${foot.toFixed(3)} m — concrete should stay planted`);
  });

  // Same pivot class as the canopy: the Hills Hoist head freewheels, so spinning
  // it has to carry the arms round. If the arms were parented to the root, the
  // head would turn and nothing would move.
  t.test('washing line head carries the arms round when spun', () => {
    const g = loaded.get('washing_line_01');
    assert(g, 'washing_line_01 did not load');
    const head = g.scene.getObjectByName('head');
    const arms = g.scene.getObjectByName('arms');
    assert(head && arms, 'washing_line_01 needs both `head` and `arms`');
    assert(arms.parent === head || arms.parent?.parent === head,
      '`arms` is not under `head` — spinning the head would move nothing');

    g.scene.updateWorldMatrix(true, true);
    const before = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
    head.rotation.y += Math.PI / 2;
    head.updateWorldMatrix(true, true);
    const after = new THREE.Vector3().setFromMatrixPosition(arms.matrixWorld);
    head.rotation.y -= Math.PI / 2;
    head.updateWorldMatrix(true, true);
    // The arms group's own origin sits on the spin axis, so its centre barely
    // moves — what must hold is that it is genuinely under the rotating node.
    assert(Number.isFinite(before.x) && Number.isFinite(after.x), 'arms world position is not finite');
  });

  // Custom props are a contract, not decoration — and I have been telling other
  // lanes to read these since Sprint 1 without ever checking they survive the
  // export. glTF `extras` arrive as three's userData, but only if export_extras
  // held all the way through; if it silently dropped, Lane A's gnome scores $0
  // and Lane B's anchors all rate the same, both of which would look like a
  // gameplay decision rather than a missing field.
  t.test('glTF extras survive as userData — the props other lanes read', () => {
    const gnome = loaded.get('garden_gnome_01')?.scene.getObjectByName('garden_gnome_01');
    assert(gnome, 'gnome root node missing');
    assert(gnome.userData?.collateral_value === 25,
      `collateral_value lost (userData=${JSON.stringify(gnome.userData)}) — Lane A scores off this`);

    const canopy = loaded.get('tree_gum_01')?.scene.getObjectByName('canopy');
    assert(typeof canopy?.userData?.sway_amp === 'number',
      'canopy.sway_amp lost — world.js per-tree sway tuning reads it');

    const branch = loaded.get('tree_gum_01')?.scene.getObjectByName('branch_anchor_01');
    assert(typeof branch?.userData?.rating_hint === 'number',
      'branch_anchor_01.rating_hint lost — Lane B picks anchors on it');

    const bin = loaded.get('wheelie_bin_01')?.scene.getObjectByName('wheelie_bin_01');
    assert(typeof bin?.userData?.mass_hint === 'number',
      'wheelie_bin mass_hint lost — Lane C throws it with this');
  });

  // The wreckage has to drop into the intact asset's place, so both variants
  // stand on the same ground plane. If the broken one floats or sinks, Lane A's
  // swap needs a fudge offset per prop and will grow one.
  t.test('broken variants sit on the same ground plane as their intact twin', () => {
    for (const [intact, broken] of [['garden_gnome_01', 'garden_gnome_01_broken'],
                                    ['fence_panel', 'fence_panel_snapped']]) {
      for (const n of [intact, broken]) {
        const box = new THREE.Box3().setFromObject(loaded.get(n).scene);
        assert(Math.abs(box.min.y) < 0.03,
          `${n} rests at y=${box.min.y.toFixed(3)}, not on the ground`);
      }
    }
  });

  // glTF has no node-visibility flag and Blender's hide_render does NOT survive
  // the export — every node arrives visible:true. That shipped as a real bug
  // from Sprint 1 to Sprint 6: garden_bed drew full + tattered + dead
  // superimposed, and the asserts missed it because they only checked the nodes
  // EXISTED. So the flag rides in extras, and this pins the flag, not the node.
  t.test('optional nodes carry hidden_by_default — hide_render does not export', () => {
    const bed = loaded.get('garden_bed')?.scene;
    assert(bed, 'garden_bed did not load');
    const on = ['plants_full'], off = ['plants_tattered', 'plants_dead'];
    for (const n of on) {
      assert(!bed.getObjectByName(n).userData?.hidden_by_default,
        `${n} should start visible — it's the default state`);
    }
    for (const n of off) {
      assert(bed.getObjectByName(n).userData?.hidden_by_default === true,
        `${n} must carry hidden_by_default or the bed renders all three wilt states at once`);
    }
    const glow = loaded.get('house_yardside')?.scene.getObjectByName('window_glow');
    assert(glow?.userData?.hidden_by_default === true,
      'window_glow must carry hidden_by_default or the house is lit at noon');
  });

  // One GLB carries three wilt states as siblings; Lane A toggles .visible
  // rather than reloading, so all three have to be present at once.
  t.test('garden_bed carries all 3 damage states in one GLB', () => {
    const g = loaded.get('garden_bed');
    assert(g, 'garden_bed did not load');
    for (const state of ['plants_full', 'plants_tattered', 'plants_dead']) {
      assert(g.scene.getObjectByName(state), `${state} missing — Lane A toggles these by name`);
    }
  });
}
