/**
 * Lane I — the `blob.body` slot, which is the only slot that is not cosmetic.
 *
 * PaintSkin stamps into a canvas through this mesh's UVs and derives
 * `boundingRadius` from its geometry — and that radius is read back as a
 * GAMEPLAY number (puddles.ts computes the belly contact point from it). The
 * frozen game.ts also captures `blob.mesh` by reference the instant createBlob
 * returns. So this slot is resolved SYNCHRONOUSLY from the preloaded cache, is
 * always a single leaf Mesh with a single MeshStandardMaterial, and its
 * geometry is re-centred and re-scaled to the procedural radius. Anything that
 * fails those tests keeps the procedural sphere and says why, out loud.
 */
import * as THREE from 'three'
import type { System, World } from '../contracts'
import { assets, normalizeToRadius, paintableInfo } from './registry'

/**
 * Returns the paintable body mesh for the blob: the swapped-in GLB mesh when
 * `blob.body` is filled AND paint-safe, otherwise the caller's own sphere.
 */
export function resolveBlobBody(
  world: World,
  radius: number,
  buildFallback: () => THREE.Mesh,
): THREE.Mesh {
  const reg = assets()
  const inst = reg.instanceSync('blob.body')
  if (!inst) {
    if (reg.has('blob.body')) {
      console.warn(
        '[assets] blob.body was not preloaded in time — keeping the built-in blob. ' +
        'Call `await initAssets()` before installGame().',
      )
    }
    return buildFallback()
  }

  const info = paintableInfo(inst.object)
  if (!info.ok) {
    console.warn(
      '[assets] blob.body cannot be painted, so the built-in blob is being used instead:\n  - ' +
      info.problems.join('\n  - '),
    )
    return buildFallback()
  }
  if (info.problems.length) {
    console.warn('[assets] blob.body loaded with warnings:\n  - ' + info.problems.join('\n  - '))
  }

  let found: THREE.Mesh | null = null
  inst.object.traverse((o) => {
    if (!found && (o as THREE.Mesh).isMesh) found = o as THREE.Mesh
  })
  const mesh = found as THREE.Mesh | null
  if (!mesh) return buildFallback()

  // Bake the manifest's offset/rotation into the geometry rather than a parent
  // node: feel.ts owns this mesh's whole local transform and would clobber it,
  // and PaintSkin raycasts in the mesh's own space. `scale` is intentionally
  // ignored — the body is always normalised back to the collider radius.
  const entry = inst.entry
  const geo = mesh.geometry.clone()
  if (entry.offset || entry.rotationDeg) {
    const m = new THREE.Matrix4()
    const k = Math.PI / 180
    const rot = entry.rotationDeg
      ? new THREE.Euler(entry.rotationDeg[0] * k, entry.rotationDeg[1] * k, entry.rotationDeg[2] * k)
      : new THREE.Euler()
    const off = entry.offset ?? [0, 0, 0]
    m.compose(new THREE.Vector3(off[0], off[1], off[2]), new THREE.Quaternion().setFromEuler(rot), new THREE.Vector3(1, 1, 1))
    geo.applyMatrix4(m)
  }
  if (entry.scale !== undefined) {
    console.warn('[assets] blob.body "scale" is ignored — the body is always fitted to the blob size.')
  }
  normalizeToRadius(geo, radius)
  mesh.geometry = geo

  // feel.ts writes `emissiveIntensity` for the super-glow and PaintSkin forces
  // the colour white; an imported material usually has neither set up.
  const mat = mesh.material as THREE.MeshStandardMaterial
  if (!mat.emissive || mat.emissive.getHex() === 0x000000) {
    mat.emissive = new THREE.Color('#88e0ff')
  }
  mat.emissiveIntensity = 0
  mesh.castShadow = true
  mesh.position.set(0, 0, 0)
  mesh.rotation.set(0, 0, 0)
  mesh.scale.set(1, 1, 1)

  installIdleClip(world, mesh, inst.object, inst.animations, entry.idleClip)
  return mesh
}

/**
 * Idle animation runs in the FIXED step, never onFrame: render frames stop
 * entirely in a hidden tab (see machine/telegraph.ts) and a rigged body's rest
 * pose is what paint UVs are read against — letting it drift with wall-clock
 * time would desync a backgrounded run from a foregrounded one.
 */
function installIdleClip(
  world: World,
  mesh: THREE.Mesh,
  root: THREE.Object3D,
  animations: THREE.AnimationClip[],
  clipName: string | undefined,
): void {
  if (!clipName) return
  const clip = animations.find((c) => c.name === clipName)
  if (!clip) {
    console.warn(
      `[assets] blob.body has no animation named "${clipName}" ` +
      `(found: ${animations.map((c) => c.name).join(', ') || 'none'}).`,
    )
    return
  }
  // The mesh is re-parented out of the GLB scene, so drive the mixer from the
  // mesh itself; bone tracks still resolve through its skeleton.
  const target = (mesh as THREE.SkinnedMesh).isSkinnedMesh ? mesh : root
  const mixer = new THREE.AnimationMixer(target)
  mixer.clipAction(clip).play()
  const system: System = { update: (dt) => mixer.update(dt) }
  world.addSystem(system)
}
