/**
 * 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 unrigged Mesh with a single material and 0..1 UVs was found. */
  ok: boolean
  uvOk: boolean
  triCount: number
  materialCount: number
  meshCount: number
  skinned: boolean
  /**
   * Connected components of the UV layout. A single hand-unwrapped island is 1;
   * three's own SphereGeometry is 3 (body + two pole fans). An auto-atlas is
   * hundreds. -1 when the geometry is non-indexed (chart count is undefined).
   */
  uvCharts: number
  /** Fraction of vertices whose position is shared but whose UV is not. */
  seamRatio: number
  /** 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

/**
 * Above this many UV islands the layout is an auto-atlas rather than an unwrap.
 * Measured: three's SphereGeometry is 3 charts, a BoxGeometry 6, a hand unwrap a
 * handful; the farm's blobbo-base.glb is 1140. A hand-authored model has no
 * business being near this number.
 */
const MAX_UV_CHARTS = 24

/** Above this many vertices, skip the chart walk rather than stall the boot. */
const CHART_VERT_LIMIT = 400_000

/** Per-url budget in `preload`. Boot awaits preload, so this is a boot budget. */
export const PRELOAD_TIMEOUT_MS = 10_000

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
}

/** Every Mesh under `root`, in traversal order. */
export function collectMeshes(root: THREE.Object3D): THREE.Mesh[] {
  const out: THREE.Mesh[] = []
  root.traverse((o) => {
    if ((o as THREE.Mesh).isMesh) out.push(o as THREE.Mesh)
  })
  return out
}

/**
 * How the UVs are laid out, which is what decides whether a splat DISC lands on
 * one patch of the model or speckles it.
 *
 * `uvOk` (0..1 range) is necessary but nowhere near sufficient: the farm's own
 * blobbo-base.glb has every UV inside 0..1 and exactly one material, yet its
 * unwrap is a 1140-island auto-atlas, so a circle stamped in UV space bleeds
 * onto a thousand unrelated triangles. Charts are counted as connected
 * components of the INDEX graph — a UV seam duplicates the position vertex, so
 * islands fall out of the topology for free.
 */
export function uvLayoutInfo(geo: THREE.BufferGeometry): { charts: number; seamRatio: number } {
  const pos = geo.getAttribute('position')
  const uv = geo.getAttribute('uv')
  const index = geo.getIndex()
  const n = pos ? pos.count : 0
  if (!pos || !uv || !index || n === 0 || n > CHART_VERT_LIMIT) {
    return { charts: -1, seamRatio: 0 }
  }

  const parent = new Int32Array(n)
  for (let i = 0; i < n; i++) parent[i] = i
  const find = (a: number): number => {
    let r = a
    while (parent[r] !== r) { parent[r] = parent[parent[r]]; r = parent[r] }
    return r
  }
  const union = (a: number, b: number): void => {
    const ra = find(a)
    const rb = find(b)
    if (ra !== rb) parent[ra] = rb
  }
  for (let i = 0; i + 2 < index.count; i += 3) {
    const a = index.getX(i)
    const b = index.getX(i + 1)
    union(a, b)
    union(b, index.getX(i + 2))
  }
  const roots = new Set<number>()
  for (let i = 0; i < n; i++) roots.add(find(i))

  // Seam ratio: vertices that share a position with a vertex carrying a
  // DIFFERENT uv — i.e. the cut edges of the atlas.
  const byPos = new Map<string, number[]>()
  for (let i = 0; i < n; i++) {
    const key = `${pos.getX(i).toFixed(4)},${pos.getY(i).toFixed(4)},${pos.getZ(i).toFixed(4)}`
    const bucket = byPos.get(key)
    if (bucket) bucket.push(i)
    else byPos.set(key, [i])
  }
  let split = 0
  for (const bucket of byPos.values()) {
    if (bucket.length < 2) continue
    const seen = new Set<string>()
    for (const i of bucket) seen.add(`${uv.getX(i).toFixed(4)},${uv.getY(i).toFixed(4)}`)
    if (seen.size > 1) split += bucket.length
  }

  return { charts: roots.size, seamRatio: split / n }
}

/**
 * Pure-ish geometry inspection. Reports whether an object can carry PaintSkin:
 * one unrigged mesh, one material, a `uv` attribute inside 0..1 laid out as a
 * small number of islands (the skin's stamp wrap assumes a contiguous unwrap, so
 * out-of-range, missing or shattered UVs mean splats land somewhere unrelated —
 * or nowhere).
 */
export function paintableInfo(object: THREE.Object3D): PaintableInfo {
  const meshes = collectMeshes(object)
  const problems: string[] = []
  if (meshes.length === 0) {
    return {
      ok: false, uvOk: false, triCount: 0, materialCount: 0, meshCount: 0,
      skinned: false, uvCharts: -1, seamRatio: 0, 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')
  const layout = uv ? uvLayoutInfo(geo) : { charts: -1, seamRatio: 0 }
  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 (layout.charts > MAX_UV_CHARTS) {
    problems.push(
      `UVs are split into ${layout.charts} separate islands (${Math.round(layout.seamRatio * 100)}% ` +
      'of the vertices sit on a seam) — this is an auto-generated atlas, so paint may scatter ' +
      'across this model instead of landing where it is thrown. Unwrap it as one island.',
    )
  }
  if (skinned) {
    problems.push(
      'this model has a skeleton/armature — rigged blobs are not supported yet, ' +
      'so the built-in body is kept. Export it without the armature (apply the pose first).',
    )
  }

  return {
    // A shattered atlas is a WARNING, not a rejection: it still paints, just
    // untidily, and rejecting it would refuse the only farm mesh that exists.
    // A skeleton is a rejection — see installIdleClip in blobBody.ts.
    ok: uvOk && materialCount === 1 && !skinned,
    uvOk, triCount: Math.round(triCount), materialCount,
    meshCount: meshes.length, skinned,
    uvCharts: layout.charts, seamRatio: layout.seamRatio,
    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.
   *
   * Every url is raced against a deadline and settled independently. A host that
   * accepts the connection and never answers would otherwise leave this promise
   * pending forever, and since boot AWAITS it, the whole game would never start
   * — turning "one custom prop is missing" into "the game is broken". A slot
   * whose url times out keeps its procedural fallback; the load itself is left
   * running, so a late arrival still populates the cache for the next attach.
   */
  async preload(timeoutMs: number = PRELOAD_TIMEOUT_MS): Promise<void> {
    const urls = new Set(Object.values(this.manifest).map((e) => e.url))
    await Promise.allSettled([...urls].map((u) => this.loadWithDeadline(u, timeoutMs)))
  }

  private loadWithDeadline(url: string, timeoutMs: number): Promise<unknown> {
    const load = this.load(url).catch(() => null)
    if (!(timeoutMs > 0)) return load
    return new Promise((resolve) => {
      const timer = setTimeout(() => {
        this.warnOnce(
          `timeout:${url}`,
          `[assets] "${url}" did not finish loading within ${Math.round(timeoutMs / 1000)}s — ` +
          'starting the game with the built-in version.',
        )
        resolve(null)
      }, timeoutMs)
      void load.then((v) => { clearTimeout(timer); resolve(v) })
    })
  }

  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, or
   * null if anything about the file makes three throw.
   *
   * Never throws, on purpose: `instanceSync` is called from `createBlob`, which
   * the frozen game.ts calls with no try/catch of its own. A throw there is a
   * black screen rather than a missing prop.
   */
  private instantiate(asset: LoadedAsset, url: string): THREE.Group | null {
    try {
      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 || !mesh.material) return
        mesh.material = Array.isArray(mesh.material)
          ? mesh.material.map((m) => m.clone())
          : mesh.material.clone()
      })
      ensureStandardMaterials(copy)
      enableShadows(copy)
      return copy
    } catch (err) {
      this.warnOnce(`inst:${url}`, `[assets] "${url}" could not be prepared — keeping the built-in version.`, err)
      return null
    }
  }

  /** Already-preloaded instance, or null. Sync and never throws — safe inside createBlob. */
  instanceSync(slot: SlotId): { object: THREE.Group; entry: SlotEntry; animations: THREE.AnimationClip[] } | null {
    try {
      const entry = this.manifest[slot]
      if (!entry) return null
      const asset = this.resolved.get(entry.url)
      if (!asset) return null
      const object = this.instantiate(asset, entry.url)
      if (!object || collectMeshes(object).length === 0) {
        if (object) {
          this.warnOnce(`nomesh:${entry.url}`, `[assets] "${entry.url}" has no mesh — keeping the built-in version.`)
        }
        return null
      }
      return { object, entry, animations: asset.animations }
    } catch (err) {
      this.warnOnce(`sync:${slot}`, `[assets] "${slot}" could not be instanced — keeping the built-in version.`, err)
      return null
    }
  }

  /**
   * 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.
   *
   * Two guards, and the split between them is the whole point. Consumers hide
   * their primitive in the FIRST statement of `onSwap`, so "primitive hidden,
   * replacement absent" is the one state the scene must never reach:
   *
   *   BUILD (instance, mesh count, parent) — a failure here happens BEFORE
   *     onSwap has run, so we bail out and the primitive is still visible.
   *   NOTIFY (onSwap) — a failure here happens AFTER the primitive may already
   *     be hidden, so the fit node STAYS parented. Losing a colour tint beats
   *     removing the only thing left to look at.
   */
  attachSlot(slot: SlotId, host: THREE.Object3D, opts: AttachOptions = {}): void {
    const entry = this.manifest[slot]
    if (!entry) return
    void this.load(entry.url).then(
      (asset) => {
        let fit: THREE.Group | null = null
        try {
          const inst = this.instantiate(asset, entry.url)
          if (!inst) return
          // A GLB whose scene is only empties/armatures/cameras loads fine and
          // instances fine. Swapping it in would hide the primitive and add
          // nothing — an invisible object with a live collider.
          if (collectMeshes(inst).length === 0) {
            this.warnOnce(`nomesh:${entry.url}`, `[assets] "${entry.url}" has no mesh — keeping the built-in version.`)
            return
          }
          fit = makeFitNode(entry, opts.fit)
          fit.add(inst)
          host.add(fit)
        } catch (err) {
          this.warnOnce(`swap:${entry.url}`, `[assets] "${entry.url}" could not be installed — keeping the built-in version.`, err)
          if (fit?.parent) fit.parent.remove(fit)
          return
        }
        try {
          opts.onSwap?.(fit)
        } catch (err) {
          this.warnOnce(`onswap:${slot}:${entry.url}`, `[assets] "${slot}" swapped in but could not be re-styled.`, err)
        }
      },
      () => { /* 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)
}
