/**
 * Lane I — the asset registry.
 *
 * Every construction site in the game asks the registry for its slot instead of
 * building a mesh directly. With an empty manifest the registry hands straight
 * back the caller's own fallback builder, untouched — that is what makes
 * "empty manifest == today's game" true by construction rather than by
 * re-implementation.
 *
 * Two shapes:
 *   `attachSlot(slot, host, opts)` — decorate an object that already exists and
 *     whose transform someone else owns (greybox boxes, the see-saw plank).
 *   `slotObject(slot, buildFallback)` — return a holder Group containing the
 *     fallback now, swapped for the GLB when it arrives.
 *
 * The holder/host is never given the manifest transform: telegraph.ts writes
 * `root.position`/`root.scale` absolutely every tick and feel.ts owns the blob
 * mesh's entire local transform. The manifest offset/rotation/scale therefore
 * always lands on a private "fit" node the registry inserts underneath.
 */
import * as THREE from 'three'
import { GLTFLoader, type GLTF } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { clone as skeletonClone } from 'three/examples/jsm/utils/SkeletonUtils.js'
import type { Manifest, SlotEntry } from './manifest'
import { fetchShippedManifest, mergeManifests } from './manifest'
import { loadOverrideManifest, isIdbUrl, idbKeyFromUrl, getBlob } from './idb'
import type { SlotId } from './slots'

export interface PaintableInfo {
  /** A single Mesh with a single material and 0..1 UVs was found. */
  ok: boolean
  uvOk: boolean
  triCount: number
  materialCount: number
  meshCount: number
  skinned: boolean
  /** Plain-language reasons the asset is not paint-safe (editor surface). */
  problems: string[]
}

interface LoadedAsset {
  scene: THREE.Group
  animations: THREE.AnimationClip[]
}

export interface AttachOptions {
  /** Called once when the GLB has arrived and been parented under `host`. */
  onSwap?: (asset: THREE.Object3D) => void
  /** Extra scale multiplied into the fit node (e.g. a puddle's slab size). */
  fit?: THREE.Vector3
}

const loader = new GLTFLoader()

/** How far outside 0..1 a UV may stray before the layout is judged unpaintable. */
const UV_MARGIN = 0.1

function degToRadTriple(d: [number, number, number]): THREE.Euler {
  const k = Math.PI / 180
  return new THREE.Euler(d[0] * k, d[1] * k, d[2] * k)
}

/** Applies a manifest entry's offset/rotation/scale to a fresh parent node. */
export function makeFitNode(entry: SlotEntry, extra?: THREE.Vector3): THREE.Group {
  const fit = new THREE.Group()
  fit.name = 'slot-fit'
  if (entry.offset) fit.position.set(entry.offset[0], entry.offset[1], entry.offset[2])
  if (entry.rotationDeg) fit.rotation.copy(degToRadTriple(entry.rotationDeg))
  if (typeof entry.scale === 'number') fit.scale.setScalar(entry.scale)
  else if (entry.scale) fit.scale.set(entry.scale[0], entry.scale[1], entry.scale[2])
  if (extra) fit.scale.multiply(extra)
  return fit
}

/** Shadows are per-mesh flags; a raw GLB import has them all off. */
export function enableShadows(root: THREE.Object3D): void {
  root.traverse((o) => {
    if ((o as THREE.Mesh).isMesh) {
      o.castShadow = true
      o.receiveShadow = true
    }
  })
}

/**
 * telegraph.ts only collects emissives from `isMeshStandardMaterial`, and
 * feel.ts writes `emissiveIntensity` unconditionally — a Basic/Lambert/Phong
 * material would silently kill the danger flash and the super-glow. Convert
 * rather than reject so an artist's export still works.
 */
export function ensureStandardMaterials(root: THREE.Object3D): number {
  let converted = 0
  root.traverse((o) => {
    const mesh = o as THREE.Mesh
    if (!mesh.isMesh) return
    const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
    const out = mats.map((m) => {
      const std = m as THREE.MeshStandardMaterial
      if (std.isMeshStandardMaterial) return m
      const src = m as THREE.MeshBasicMaterial
      converted++
      return new THREE.MeshStandardMaterial({
        color: src.color ? src.color.clone() : new THREE.Color('#ffffff'),
        map: src.map ?? null,
        transparent: src.transparent,
        opacity: src.opacity,
        side: src.side,
      })
    })
    mesh.material = Array.isArray(mesh.material) ? out : out[0]
  })
  return converted
}

/**
 * Pure-ish geometry inspection. Reports whether an object can carry PaintSkin:
 * one mesh, one material, a `uv` attribute inside 0..1 (the skin's stamp wrap
 * assumes an equirectangular layout, so out-of-range or missing UVs mean splats
 * land somewhere unrelated — or nowhere).
 */
export function paintableInfo(object: THREE.Object3D): PaintableInfo {
  const meshes: THREE.Mesh[] = []
  object.traverse((o) => {
    if ((o as THREE.Mesh).isMesh) meshes.push(o as THREE.Mesh)
  })
  const problems: string[] = []
  if (meshes.length === 0) {
    return {
      ok: false, uvOk: false, triCount: 0, materialCount: 0, meshCount: 0,
      skinned: false, problems: ['no mesh in the file'],
    }
  }
  if (meshes.length > 1) problems.push(`${meshes.length} meshes — only the first is painted`)

  const mesh = meshes[0]
  const skinned = (mesh as THREE.SkinnedMesh).isSkinnedMesh === true
  const materialCount = Array.isArray(mesh.material) ? mesh.material.length : 1
  if (materialCount > 1) {
    problems.push(`${materialCount} materials — paint needs exactly one (splats would be invisible)`)
  }

  const geo = mesh.geometry
  const pos = geo.getAttribute('position')
  const index = geo.getIndex()
  const triCount = index ? index.count / 3 : (pos ? pos.count / 3 : 0)

  const uv = geo.getAttribute('uv')
  let uvOk = false
  if (!uv) {
    problems.push('no UV map — the paint has nowhere to go')
  } else {
    let min = Infinity
    let max = -Infinity
    for (let i = 0; i < uv.count * uv.itemSize; i++) {
      const v = uv.array[i] as number
      if (!Number.isFinite(v)) { min = NaN; break }
      if (v < min) min = v
      if (v > max) max = v
    }
    // Tolerance, not equality: three's own SphereGeometry — the mesh this game
    // ships — pushes pole UVs to ±0.5/widthSegments outside 0..1, so a strict
    // test would reject the stock blob. What actually breaks paint is an ATLAS
    // or tiled layout (u running 0..4), which is far outside this margin.
    uvOk = Number.isFinite(min) && min >= -UV_MARGIN && max <= 1 + UV_MARGIN
    if (!uvOk) {
      problems.push(`UVs run ${Number.isFinite(min) ? min.toFixed(2) : '?'}..${Number.isFinite(max) ? max.toFixed(2) : '?'} — they must stay inside 0..1 (one unwrapped island, no tiling)`)
    }
  }
  if (skinned) problems.push('rigged mesh — paint is placed using the rest pose')

  return {
    ok: uvOk && materialCount === 1,
    uvOk, triCount: Math.round(triCount), materialCount,
    meshCount: meshes.length, skinned, problems,
  }
}

/**
 * Re-centres and re-sizes geometry so its bounding sphere is exactly
 * `radius` around the origin. PaintSkin derives `boundingRadius` from this and
 * that number is GAMEPLAY (puddles.ts computes belly contact as
 * `y - boundingRadius * size`), so a custom body must not be allowed to move it.
 */
export function normalizeToRadius(geo: THREE.BufferGeometry, radius: number): void {
  geo.computeBoundingSphere()
  const bs = geo.boundingSphere
  if (!bs || bs.radius <= 1e-6) return
  geo.translate(-bs.center.x, -bs.center.y, -bs.center.z)
  geo.scale(radius / bs.radius, radius / bs.radius, radius / bs.radius)
  geo.computeBoundingSphere()
  geo.computeVertexNormals()
}

export class AssetRegistry {
  readonly manifest: Manifest
  private readonly cache = new Map<string, Promise<LoadedAsset>>()
  private readonly resolved = new Map<string, LoadedAsset>()
  private readonly objectUrls = new Map<string, string>()
  private readonly warned = new Set<string>()

  constructor(manifest: Manifest = {}) {
    this.manifest = manifest
  }

  /** Shipped pack, then the editor's local override (later wins). */
  static async create(): Promise<AssetRegistry> {
    const [shipped, local] = await Promise.all([
      fetchShippedManifest(),
      loadOverrideManifest(),
    ])
    const reg = new AssetRegistry(mergeManifests(shipped, local))
    await reg.preload()
    return reg
  }

  entry(slot: SlotId): SlotEntry | undefined {
    return this.manifest[slot]
  }

  has(slot: SlotId): boolean {
    return this.manifest[slot] !== undefined
  }

  /**
   * Resolve every url the manifest mentions. Integration must await this before
   * building the game: `blob.mesh`'s identity is captured by reference in the
   * frozen game.ts, so the body asset cannot arrive after createBlob returns.
   */
  async preload(): Promise<void> {
    const urls = new Set(Object.values(this.manifest).map((e) => e.url))
    await Promise.all([...urls].map((u) => this.load(u).catch(() => null)))
  }

  private warnOnce(key: string, ...args: unknown[]): void {
    if (this.warned.has(key)) return
    this.warned.add(key)
    console.warn(...args)
  }

  private async resolveUrl(url: string): Promise<string> {
    if (!isIdbUrl(url)) return url
    const cached = this.objectUrls.get(url)
    if (cached) return cached
    const buf = await getBlob(idbKeyFromUrl(url))
    if (!buf) throw new Error(`no local asset stored under "${idbKeyFromUrl(url)}"`)
    const objUrl = URL.createObjectURL(new Blob([buf], { type: 'model/gltf-binary' }))
    this.objectUrls.set(url, objUrl)
    return objUrl
  }

  private load(url: string): Promise<LoadedAsset> {
    const hit = this.cache.get(url)
    if (hit) return hit
    const p = (async () => {
      const real = await this.resolveUrl(url)
      const gltf: GLTF = await loader.loadAsync(real)
      const asset: LoadedAsset = { scene: gltf.scene, animations: gltf.animations }
      this.resolved.set(url, asset)
      return asset
    })()
    p.catch((err) => {
      this.warnOnce(`load:${url}`, `[assets] could not load "${url}" — keeping the built-in version.`, err)
    })
    this.cache.set(url, p)
    return p
  }

  /** A cloned, shadow-enabled, standard-material instance of a loaded url. */
  private instantiate(asset: LoadedAsset): THREE.Group {
    const copy = skeletonClone(asset.scene) as THREE.Group
    // SkeletonUtils.clone shares materials with the source. Instances get tinted
    // per-slot (puddle colour, ghost translucency), so each needs its own.
    copy.traverse((o) => {
      const mesh = o as THREE.Mesh
      if (!mesh.isMesh) return
      mesh.material = Array.isArray(mesh.material)
        ? mesh.material.map((m) => m.clone())
        : mesh.material.clone()
    })
    ensureStandardMaterials(copy)
    enableShadows(copy)
    return copy
  }

  /** Already-preloaded instance, or null. Sync — safe inside createBlob. */
  instanceSync(slot: SlotId): { object: THREE.Group; entry: SlotEntry; animations: THREE.AnimationClip[] } | null {
    const entry = this.manifest[slot]
    if (!entry) return null
    const asset = this.resolved.get(entry.url)
    if (!asset) return null
    return { object: this.instantiate(asset), entry, animations: asset.animations }
  }

  /**
   * Parent the slot's asset under an object that already exists. The host's own
   * transform is never touched — only a private fit node is added.
   */
  attachSlot(slot: SlotId, host: THREE.Object3D, opts: AttachOptions = {}): void {
    const entry = this.manifest[slot]
    if (!entry) return
    void this.load(entry.url).then(
      (asset) => {
        const fit = makeFitNode(entry, opts.fit)
        fit.add(this.instantiate(asset))
        host.add(fit)
        opts.onSwap?.(fit)
      },
      () => { /* warned in load(); fallback stays */ },
    )
  }

  /**
   * Synchronous placeholder: returns a holder Group containing `buildFallback()`
   * right away. If the slot is filled, the fallback is removed and the GLB takes
   * its place as soon as it loads (immediately, if preload already ran).
   */
  slotObject(slot: SlotId, buildFallback: () => THREE.Object3D): THREE.Object3D {
    const fallback = buildFallback()
    const entry = this.manifest[slot]
    if (!entry) return fallback

    const holder = new THREE.Group()
    holder.name = `slot:${slot}`
    holder.add(fallback)
    this.attachSlot(slot, holder, {
      onSwap: () => { holder.remove(fallback) },
    })
    return holder
  }

  /** Editor/demo surface: how paint-safe is whatever is in `blob.body`? */
  paintability(slot: SlotId = 'blob.body'): PaintableInfo | null {
    const inst = this.instanceSync(slot)
    return inst ? paintableInfo(inst.object) : null
  }

  dispose(): void {
    for (const url of this.objectUrls.values()) URL.revokeObjectURL(url)
    this.objectUrls.clear()
  }
}

// ---- module singleton ------------------------------------------------------
// Construction sites live in files owned by other lanes and are called from the
// frozen game.ts, so their signatures cannot grow a `registry` parameter. A
// singleton set once at boot is the only wiring that needs no frozen edit
// beyond a single `await initAssets()` in main.ts.

let current = new AssetRegistry({})
let booting: Promise<AssetRegistry> | null = null

/** The active registry. Always non-null — an empty one until initAssets runs. */
export function assets(): AssetRegistry {
  return current
}

/** Await before building the game. Idempotent. */
export function initAssets(): Promise<AssetRegistry> {
  if (!booting) {
    booting = AssetRegistry.create().then(
      (reg) => { current = reg; return reg },
      (err) => {
        console.warn('[assets] registry failed to start — using built-in assets only.', err)
        return current
      },
    )
  }
  return booting
}

/** Tests/demos: install a hand-built registry without touching the network. */
export function setAssets(reg: AssetRegistry): void {
  current = reg
  booting = Promise.resolve(reg)
}
