// A loaded GLB model — its own node kind (a GLB is a node hierarchy with baked animation clips),
// distinct from Mesh (raw primitives, no animation). Animation is always present, reached as
// model.anim (an Animator over the GLB's clips — crossfades, blend spaces, layers when you need them):
//   const hero = await Model.load(asset('./hero.glb'))
//   hero.anim.play('Run', { loop: true })

import { fetch, type FetchResponse } from "../runtime/fetch"
import { Node } from "./Node"
import { Animator, _pushLod, type LodMode } from "./animation/Animator"
import { Material } from "./Material"

export class Model extends Node {
  /** The model's Animator — always present, its clip table = the GLB's embedded clips. Configure
   *  more (external clips, blend spaces, layers) with `model.aspect(Animator, {...})`. */
  declare readonly anim: Animator
  /** @internal loaded through the lightmap material (docs/lightmap-plan.md); clones inherit it. */
  _lightmapped = false
  /** @internal what `load` does without an explicit `lightmap` option: `'dynamic'` while a scene file with
   *  `env.lightmap.volume` runs (Lightmap sets it), so weapons, spawns and make() subtrees read the volume. */
  static _lightmapDefault: false | "dynamic" = false
  /** @internal the shadow pair, mirrored here because the bridge writes both at once. */
  _castShadows = true
  /** @internal */
  _receiveShadows = true
  /** @internal */
  _culling = true
  /** @internal -1 = auto (see `lod`) */
  _lodMesh = -1

  constructor(internalId: number) {
    super(internalId)
    this.aspect(Animator)
  }

  /** Does this model cast a real-time shadow? Unlike Mesh (one renderable) a GLB is a whole
   *  hierarchy, so the flag goes to EVERY renderable of the instance. A first-person viewmodel —
   *  arms, weapon, attachments — sets it false: it lives in front of the camera and its shadow
   *  is never wanted. */
  get castShadows(): boolean { return this._castShadows }
  set castShadows(v: boolean) { this._castShadows = v; this._applyShadows() }

  /** Is this model lit by other casters' shadows? Same instance-wide reach as castShadows. */
  get receiveShadows(): boolean { return this._receiveShadows }
  set receiveShadows(v: boolean) { this._receiveShadows = v; this._applyShadows() }

  /** Frustum culling for the whole instance: a model the camera cannot see skips the draw and the
   *  shadow pass. ON by default where the host keeps a skinned mesh's bounds honest — the engine refits
   *  them to the joints every frame, so a walking, kneeling or ragdolled body is never culled while on
   *  screen (`_creator.skinnedCullingSupported`); OFF (always draw) on hosts without that, where
   *  Filament would cull an animated body by its bind-pose box. `false` = always draw (a skybox-sized
   *  mesh, a debugging aid); `true` forces it on regardless of the host. */
  get culling(): boolean { return this._culling }
  set culling(v: boolean) { this._culling = v; _creator.setGlbCulling(this.id, v) }
  /** Level of detail (docs/lod-plan.md). `'auto'` (default): the engine shows the `_LOD<n>` mesh level
   *  that fits the model's size on screen (`lecodes assets doctor --lod` makes them) and scales the
   *  animation rate with it; a number 0–3 pins that level for both — `0` = always full detail (a hero,
   *  a showcase), `2`/`3` = always cheap (a crowd filler). `model.anim.lod = 'full'` keeps the animation
   *  exact while the mesh still switches. No-op on hosts without the LOD pass. */
  get lod(): LodMode { return this._lodMesh < 0 ? "auto" : (this._lodMesh as 0 | 1 | 2 | 3) }
  set lod(v: LodMode) { this._lodMesh = v === "auto" ? -1 : Math.max(0, Math.min(3, Math.round(v))); _pushLod(this) }

  /** @internal the default for a fresh instance (load / clone), decided once per host. */
  static _cullingDefault(): boolean {
    if (Model._cullDefault === undefined) Model._cullDefault = !!_creator.skinnedCullingSupported?.()
    return Model._cullDefault
  }
  private static _cullDefault: boolean | undefined

  /** @internal both flags travel together — the bridge walks the instance once. */
  _applyShadows(): void { _creator.setGlbShadows?.(this.id, this._castShadows, this._receiveShadows) }

  /** Duplicate this model — a deep copy of the GLB (meshes, skeleton, animation clips), attached to
   *  the same parent and scene and sharing this model's current transform. The clone has its own
   *  independent animation state (reach it via clone.anim). Mirrors this model's culling flag. */
  clone(): Model {
    const id = _creator.cloneEntity(this.id)
    const m = new Model(id)
    m._lightmapped = this._lightmapped
    if (!this._culling) { m._culling = false; _creator.setGlbCulling(id, false) }   // the engine default is on
    if (this._lodMesh >= 0) { m._lodMesh = this._lodMesh; _pushLod(m) }
    // the clone is a FRESH gltfio instance — it comes back with the asset's own shadow flags,
    // not this model's, so a cleared flag has to be re-applied
    m._castShadows = this._castShadows
    m._receiveShadows = this._receiveShadows
    if (!this._castShadows || !this._receiveShadows) m._applyShadows()
    return m
  }

  /** Load a GLB model. Returns its root as a Model; play its baked clips via model.anim. */
  static load(
    source: string | FetchResponse,
    options: {
      /** Frustum culling — see `culling` (default: on where the host refits skinned bounds, else off). */
      culling?: boolean
      onProgress?: (p: { loaded: number, total?: number }) => void
      /** Baked lighting (docs/lightmap-plan.md). `true` = a STATIC: loads through the lightmap material so
       *  `Lightmap.load` can bind its atlas rect (the GLB needs TEXCOORD_1 — `lecodes assets doctor
       *  --lightmap-uv`). `'dynamic'` = a mover: the same material, lit by the level's light VOLUME instead
       *  (no UV1 needed). Omitted: `'dynamic'` while a scene file with `env.lightmap.volume` is running,
       *  else off. `false` = the standard shader. Hosts without lightmap support ignore it. */
      lightmap?: boolean | "dynamic"
    } = {},
  ): Promise<Model> {
    if (typeof source === "string") {
      return fetch(source, { useOnce: true, onProgress: options.onProgress }).then((resp) => {
        if (resp.status >= 400) return Promise.reject(new Error(`Failed to load GLB from ${source}. HTTP ${resp.status}`))
        return Model.load(resp, options)
      })
    }
    return new Promise<Model>((resolve, reject) => {
      const mode = options.lightmap ?? Model._lightmapDefault
      const lightmapped = !!mode && !!_creator.setNextGlbLightmapped
      if (lightmapped) _creator.setNextGlbLightmapped!(Material.idOf(Material._lightmapTemplate()))
      _creator.createGlb((source as unknown as { _id: number })._id, (entityId: number) => {
        if (entityId === 0) { reject(new Error("Failed to load GLB")); return }
        const model = new Model(entityId)
        model._lightmapped = lightmapped
        const culling = options.culling ?? Model._cullingDefault()
        if (!culling) { model._culling = false; _creator.setGlbCulling(entityId, false) }
        resolve(model)
      }, reject)
    })
  }
}
