/**
 * Headless reproductions of the asset-runtime failures found in review. Run:
 *   node --import ./scripts/ts-resolve.mjs --experimental-strip-types src/assets/harden.test.ts
 *
 * Each block first states the OBSERVABLE failure it is pinning down, because
 * every one of these was reproducible before the fix and silent in a browser.
 * No network and no renderer: assets are injected straight into the registry's
 * cache, which is exactly the state `preload()` leaves it in.
 */
import * as THREE from 'three'
import type { World } from '../contracts'
import { AssetRegistry, setAssets, paintableInfo, uvLayoutInfo, collectMeshes } from './registry'
import { resolveBlobBody, fitBodyToRadius } from './blobBody'
import { validateManifest } from './manifest'
import { SLOT_IDS, SLOT_LABELS } from './slots'

let passed = 0
function ok(cond: boolean, msg: string): void {
  if (!cond) throw new Error('FAIL: ' + msg)
  passed++
}
function near(a: number, b: number, msg: string, eps = 1e-5): void {
  ok(Math.abs(a - b) <= eps, `${msg} (got ${a}, want ${b})`)
}

/** Put a scene in the registry's cache as if preload() had fetched it. */
function inject(reg: AssetRegistry, url: string, scene: THREE.Group): void {
  const asset = { scene, animations: [] as THREE.AnimationClip[] }
  const priv = reg as unknown as {
    cache: Map<string, Promise<unknown>>
    resolved: Map<string, unknown>
  }
  priv.cache.set(url, Promise.resolve(asset))
  priv.resolved.set(url, asset)
}

function sphereGlb(radius = 1): THREE.Group {
  const g = new THREE.Group()
  g.add(new THREE.Mesh(new THREE.SphereGeometry(radius, 24, 18), new THREE.MeshStandardMaterial()))
  return g
}

const warnings: string[] = []
const realWarn = console.warn
console.warn = (...a: unknown[]) => { warnings.push(a.map(String).join(' ')) }
function drainWarnings(): string[] {
  const out = warnings.slice()
  warnings.length = 0
  return out
}
const tick = (): Promise<void> => new Promise((r) => setTimeout(r, 0))

// ---- B1: a mesh-less GLB must not blank the slot ----------------------------
// Before: onSwap fired unconditionally, so greybox hid the box material and
// added nothing — an invisible prop with a live collider.
{
  const reg = new AssetRegistry({ 'course.scenery.block': { url: 'rig-only.glb' } })
  const empty = new THREE.Group()
  empty.add(new THREE.Object3D()) // an armature/empty, no Mesh anywhere
  inject(reg, 'rig-only.glb', empty)

  const box = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
  let swapped = false
  reg.attachSlot('course.scenery.block', box, {
    onSwap: () => { swapped = true; (box.material as THREE.Material).visible = false },
  })
  await tick()
  ok(!swapped, 'a mesh-less GLB never fires onSwap')
  ok((box.material as THREE.Material).visible, 'the procedural box is still visible')
  ok(box.children.length === 0, 'no empty fit node was parented')
  ok(drainWarnings().some((w) => w.includes('has no mesh')), 'it says so, once')
}

// blob.face goes through slotObject, which is the same guard one level up.
{
  const reg = new AssetRegistry({ 'blob.face': { url: 'rig-only.glb' } })
  inject(reg, 'rig-only.glb', new THREE.Group())
  const eyes = new THREE.Object3D()
  const holder = reg.slotObject('blob.face', () => eyes)
  await tick()
  ok(holder.children.includes(eyes), 'the eyes survive a mesh-less blob.face asset')
  drainWarnings()
}

// ---- B2: nothing thrown in the swap path may escape or half-swap ------------
{
  // (a) a throw BEFORE onSwap leaves the primitive visible and adds nothing.
  const reg = new AssetRegistry({ 'machine.boot': { url: 'bad.glb' } })
  const scene = sphereGlb()
  // A material that explodes on clone() — instantiate()'s per-instance clone.
  const mesh = collectMeshes(scene)[0]
  ;(mesh.material as THREE.Material).clone = () => { throw new Error('boom in clone') }
  inject(reg, 'bad.glb', scene)

  const host = new THREE.Object3D()
  let swapped = false
  reg.attachSlot('machine.boot', host, { onSwap: () => { swapped = true } })
  await tick()
  ok(!swapped, 'a throw during instancing never reaches onSwap')
  ok(host.children.length === 0, 'nothing half-parented')
  ok(drainWarnings().some((w) => w.includes('keeping the built-in version')), 'warned instead of rejecting a promise')
}
{
  // (b) a throw INSIDE onSwap must not remove the replacement. Consumers hide
  // their primitive on onSwap's first line, so removing the fit node here would
  // manufacture the exact "primitive hidden, replacement missing" state.
  const reg = new AssetRegistry({ 'fx.puddle': { url: 'ok.glb' } })
  inject(reg, 'ok.glb', sphereGlb())
  const slab = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
  reg.attachSlot('fx.puddle', slab, {
    onSwap: () => {
      ;(slab.material as THREE.Material).visible = false // consumers do this FIRST
      throw new Error('boom in onSwap')
    },
  })
  await tick()
  ok(slab.children.length === 1, 'the replacement stays in the scene when onSwap throws')
  ok(collectMeshes(slab.children[0]).length === 1, 'and it still contains the mesh')
  ok(drainWarnings().some((w) => w.includes('could not be re-styled')), 'the styling failure is reported')
}
{
  // (c) instanceSync is called from createBlob, which frozen game.ts calls with
  // no try/catch — a throw there is a black screen, not a missing prop.
  const reg = new AssetRegistry({ 'blob.body': { url: 'bad.glb' } })
  const scene = sphereGlb()
  const mesh = collectMeshes(scene)[0]
  ;(mesh.material as THREE.Material).clone = () => { throw new Error('boom in clone') }
  inject(reg, 'bad.glb', scene)
  setAssets(reg)

  const built = new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 36), new THREE.MeshStandardMaterial())
  const fakeWorld = { addSystem: () => {} } as unknown as World
  const out = resolveBlobBody(fakeWorld, 0.5, () => built)
  ok(out === built, 'a throwing blob.body falls back to the built-in blob instead of killing the boot')
  ok(reg.instanceSync('blob.body') === null, 'instanceSync returns null rather than throwing')
  drainWarnings()
}

// ---- B3: preload must not hang the boot -------------------------------------
{
  const reg = new AssetRegistry({ 'machine.fan': { url: 'stalled.glb' } })
  const priv = reg as unknown as { cache: Map<string, Promise<unknown>> }
  priv.cache.set('stalled.glb', new Promise(() => { /* a host that never answers */ }))
  const t0 = Date.now()
  await reg.preload(60) // same code path, a deadline you can wait for in a test
  const dt = Date.now() - t0
  ok(dt < 2000, `preload gave up on a stalled url in ${dt}ms instead of never resolving`)
  ok(drainWarnings().some((w) => w.includes('did not finish loading')), 'the stalled url is named')
  ok(reg.instanceSync('machine.fan') === null, 'the slot simply stays on its fallback')
}
{
  // allSettled semantics: one bad url must not starve a good one.
  const reg = new AssetRegistry({
    'machine.fan': { url: 'stalled.glb' },
    'machine.belt': { url: 'good.glb' },
  })
  const priv = reg as unknown as { cache: Map<string, Promise<unknown>> }
  priv.cache.set('stalled.glb', new Promise(() => {}))
  inject(reg, 'good.glb', sphereGlb())
  await reg.preload(60)
  ok(reg.instanceSync('machine.belt') !== null, 'the healthy asset still loaded')
  drainWarnings()
}

// ---- ghost sizing -----------------------------------------------------------
// Before: the ghost borrowed blob.body raw, so a 1.084-radius farm mesh
// rendered the ghost 2.17x the blob it is meant to mirror.
{
  const raw = sphereGlb(1.0841) // blobbo-base.glb's measured bounding radius
  const fit = new THREE.Group()
  fit.scale.setScalar(3) // a fit node built from a manifest `scale`, as attachSlot builds it
  fit.add(raw)
  const mesh = fitBodyToRadius(fit, 0.5)
  ok(mesh !== null, 'the shared fit found the body mesh')
  fit.updateMatrixWorld(true)
  const box = new THREE.Box3().setFromObject(fit)
  const size = new THREE.Vector3()
  box.getSize(size)
  near(size.x / 2, 0.5, 'the ghost body ends up at the blob radius in WORLD space')
  near(box.getCenter(new THREE.Vector3()).length(), 0, 'and centred on the ghost origin')
}
{
  // The blob and the ghost must agree for the same asset — that is the point.
  const reg = new AssetRegistry({ 'blob.body': { url: 'body.glb', scale: 4, offset: [3, 0, 0] } })
  inject(reg, 'body.glb', sphereGlb(1.0841))
  setAssets(reg)
  const fakeWorld = { addSystem: () => {} } as unknown as World
  const blobMesh = resolveBlobBody(fakeWorld, 0.5, () => new THREE.Mesh())
  blobMesh.geometry.computeBoundingSphere()

  const ghostInst = reg.instanceSync('blob.body')!
  const ghostFit = new THREE.Group()
  ghostFit.add(ghostInst.object)
  const ghostMesh = fitBodyToRadius(ghostFit, 0.5, ghostInst.entry)!
  ghostMesh.geometry.computeBoundingSphere()
  near(
    ghostMesh.geometry.boundingSphere!.radius,
    blobMesh.geometry.boundingSphere!.radius,
    'ghost and blob resolve to the same radius from the same asset',
  )
  drainWarnings()
}

// ---- skinned blob.body is rejected loudly, not repaired ---------------------
{
  const geo = new THREE.SphereGeometry(0.5, 16, 12)
  const bone = new THREE.Bone()
  const skinned = new THREE.SkinnedMesh(geo, new THREE.MeshStandardMaterial())
  skinned.add(bone)
  const info = paintableInfo(skinned)
  ok(info.skinned, 'the skeleton is detected')
  ok(!info.ok, 'a rigged body is REJECTED — the skeleton is orphaned by the design and normalising invalidates the bind matrices')
  ok(info.problems.some((p) => p.includes('skeleton/armature')), 'in plain language')
}
{
  // ...and no AnimationMixer system is registered for it, so we are not burning
  // fixed-step CPU animating something that is not in the scene graph.
  const reg = new AssetRegistry({ 'blob.body': { url: 'body.glb', idleClip: 'Idle' } })
  inject(reg, 'body.glb', sphereGlb())
  setAssets(reg)
  const systems: unknown[] = []
  const fakeWorld = { addSystem: (s: unknown) => systems.push(s) } as unknown as World
  resolveBlobBody(fakeWorld, 0.5, () => new THREE.Mesh())
  ok(systems.length === 0, 'idleClip registers no fixed-step system')
  ok(drainWarnings().some((w) => w.includes('idleClip')), 'and says the clip is ignored')
}

// ---- UV atlas detection -----------------------------------------------------
// blobbo-base.glb passes every other check (0..1 UVs, one material) and still
// paints wrong: it is a 1140-island auto-atlas.
{
  const sphere = uvLayoutInfo(new THREE.SphereGeometry(0.5, 48, 36))
  ok(sphere.charts === 3, `three's own sphere is 3 charts (body + 2 pole fans), got ${sphere.charts}`)
  ok(paintableInfo(new THREE.Mesh(new THREE.SphereGeometry(0.5, 48, 36), new THREE.MeshStandardMaterial())).problems.length === 0,
    'the stock blob is NOT flagged as an atlas')

  // A shattered layout: same sphere, every triangle its own island.
  const shattered = new THREE.SphereGeometry(0.5, 24, 18).toNonIndexed()
  const idx: number[] = []
  for (let i = 0; i < shattered.getAttribute('position').count; i++) idx.push(i)
  shattered.setIndex(idx)
  const info = uvLayoutInfo(shattered)
  ok(info.charts > 24, `a per-triangle atlas is detected (${info.charts} charts)`)
  const p = paintableInfo(new THREE.Mesh(shattered, new THREE.MeshStandardMaterial()))
  ok(p.problems.some((s) => s.includes('islands')), 'and reported as a scatter risk')
  ok(p.ok, 'but still accepted — it paints untidily, it does not fail to paint')
}

// ---- manifest metadata keys -------------------------------------------------
{
  const res = validateManifest({
    _readme: 'exported by the BLOBBO workshop',
    _generatedBy: 'workshop 1.0',
    'machine.boot': { url: 'a.glb' },
    'machine.tractor': { url: 'b.glb' },
  })
  ok(res.problems.length === 1, `only the real typo is reported (got ${res.problems.length})`)
  ok(res.problems[0].slot === 'machine.tractor', 'and it is the typo, not the metadata')
  ok(Object.keys(res.manifest).length === 1, 'metadata never becomes a slot')
}

// ---- slot vocabulary --------------------------------------------------------
{
  for (const id of ['cannon.base', 'course.finish', 'course.tunnel', 'course.tramp'] as const) {
    ok(SLOT_IDS.includes(id), `${id} is in the canonical list the editor imports`)
    ok(typeof SLOT_LABELS[id] === 'string' && SLOT_LABELS[id].length > 0, `${id} has a label`)
  }
  ok(SLOT_IDS.length === new Set(SLOT_IDS).size, 'no duplicate slot ids')
  ok(SLOT_IDS.every((id) => SLOT_LABELS[id]), 'every slot id has a label')
}

// ---- multi-instance slots ---------------------------------------------------
// Nine puddles and six cannons share one slot id. Each must get its OWN clone at
// its OWN pose — one shared node would teleport eight puddles onto the ninth.
{
  const reg = new AssetRegistry({ 'fx.puddle': { url: 'decal.glb' } })
  inject(reg, 'decal.glb', sphereGlb(0.5))
  const slabs = [-10, 0, 10].map((x) => {
    const m = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial())
    m.position.set(x, 0, 0)
    return m
  })
  for (const s of slabs) {
    reg.attachSlot('fx.puddle', s, {
      fit: new THREE.Vector3(2, 1, 2),
      onSwap: () => { (s.material as THREE.Material).visible = false },
    })
  }
  await tick()
  const roots = slabs.map((s) => s.children[0])
  ok(roots.every((r) => r !== undefined), 'every instance got a replacement')
  ok(new Set(roots).size === 3, 'three distinct fit nodes, not one shared node')
  const mats = slabs.map((s) => collectMeshes(s)[0].material)
  ok(new Set(mats).size === 3, 'and three distinct materials, so per-instance tinting cannot bleed')
  const world = new THREE.Vector3()
  collectMeshes(slabs[2])[0].getWorldPosition(world)
  near(world.x, 10, 'each clone sits at its own instance pose')
  drainWarnings()
}

console.warn = realWarn
console.log(`harden.test.ts: ${passed} checks passed`)
