// Animator — THE animation system, reached as `node.anim`. Every Model carries one pre-attached (its
// clip list = the GLB's embedded clips; nothing native exists until the first play()). The whole
// per-frame loop — fades, blend spaces, one-shot fade-back, root motion — runs in the engine; the
// SDK only issues calls. See docs/animator-plan.md.
//
//   const hero = await Model.load(asset('./hero.glb'))
//   hero.anim.playLoop('Idle')                             // the layer's LOOP: what it rests on
//   await hero.anim.play('Jump', { fadeIn: 0.1, fadeOut: 0.2 })   // a one-shot over it, back to the loop after
//
//   hero.anim.addClip('slash', slash)                      // clips from other files / procedural / sliced
//   const loco = hero.anim.playLoop({ Idle: 0, Walking: 2, 'Fast Run': 6 })   // a blend as the loop
//   loco.value = hero.controller.velocity.length           // where we are on the blend axis
//   const kick = hero.anim.play('slash', { fadeIn: 0.1, fadeOut: 0.3 })   // resolves at its hand-over
//   kick.clip.addEvent(0.4, 'hit'); hero.anim.on('hit', () => …)
//   if (await kick) hero.anim.playLoop('Crouch', { fade: 0.3 })   // resolved at the hand-over: what you start now is what it fades into
//   const upper = hero.anim.addLayer({ mask: 'Spine1' })
//   upper.playLoop('Aim', { fade: 0.3 })                   // arms aim, legs keep walking
//
// Any Node hierarchy works, not just Models: tracks bind to child names (a robot arm of meshes).

import { Aspect } from "../../core/Aspect"
import type { Node } from "../Node"
import type { AnimationClip } from "./AnimationClip"
import { Core, type ActiveClip, type ClipEventHandler, type LayerOptions, type LoopDef, type LoopOptions, type PlayOptions, type StopOptions } from "./core"
import type { Loop } from "./Loop"
import type { Layer } from "./Layer"
import type { Playback } from "./Playback"

/** Level of detail for a GLB instance (docs/lod-plan.md): `'auto'` = the engine's pick by screen size and
 *  visibility, or a fixed level 0 (full) … 3 (coarsest mesh, animation every 4th frame without fingers). */
export type LodMode = "auto" | 0 | 1 | 2 | 3

/** @internal Push a node's LOD override to the host: the Model's mesh level and the Animator's rate
 *  (an Animator set to 'full' wins over a model level — the pose stays exact, the mesh may still coarsen). */
export const _pushLod = (node: Node): void => {
  if (!_creator.setLod) return
  const mesh = (node as { _lodMesh?: number })._lodMesh ?? -1
  const anim = node.get(Animator)
  _creator.setLod(node.id, mesh, anim?._lod === "full" ? 0 : mesh)
}

export class Animator extends Aspect<"anim", Node> {
  static readonly aspect = "anim"
  private _c!: Core
  private _rootMotion = false
  private _rootRotation = false
  /** @internal */
  _lod: "auto" | "full" = "auto"

  /** Animation level of detail. `'auto'` (default): a character small on screen or out of view is
   *  evaluated every 2nd / 4th frame and stops sampling its finger, toe and twist joints; `'full'`:
   *  every frame, every joint, whatever the distance — a hero seen through a scope, a cutscene actor.
   *  Independent of `Model.lod` (the mesh level). No-op on hosts without the LOD pass. */
  get lod(): "auto" | "full" { return this._lod }
  set lod(v: "auto" | "full") { this._lod = v; _pushLod(this.node) }

  onAttach(): void { this._c = new Core(this.node) }
  onDetach(): void { this._c.destroy() }

  // ---- clips ----
  /** The clip list, in order: the GLB's embedded clips (file order), then the clips you added. An
   *  added clip with an embedded clip's name takes its place. */
  get clips(): readonly AnimationClip[] { return this._c.clips() }
  /** One clip by name, or by index into `clips` — e.g. to `slice()` it. `undefined` if there is none. */
  clip(ref: string | number): AnimationClip | undefined { return this._c.resolveClip(ref)?.[1] }
  /** Add a clip under a name (default: its own `name`) — from another file, procedural, or sliced.
   *  Overrides an embedded clip of the same name. Chainable. */
  addClip(name: string | AnimationClip, clip?: AnimationClip): this {
    const c = typeof name === "string" ? clip : name
    const n = typeof name === "string" ? name : name.name
    if (!c) { console.warn(`Animator.addClip('${n}'): no clip`); return this }
    this._c.addClip(n, c)
    return this
  }

  // ---- playing (the base layer) ----
  /** Play a one-shot on the base layer — by name, index, or the first clip with no argument.
   *  Crossfades per the options (default: cut). Returns a Playback: `await` it — it resolves at the
   *  clip's hand-over (`true`; `false` if cut short), and what you start right after is what the clip
   *  fades into (another one-shot, `playLoop(next)`; nothing = back to the loop). */
  play(clip?: string | number, options: PlayOptions = {}): Playback { return this._c.play(this._c.layers[0], clip, options) }
  /** Set the base layer's LOOP — what it shows when no one-shot plays: a clip (`playLoop('Idle')`) or
   *  a blend space (`playLoop({ Idle: 0, Run: 6 })`, drive the returned object's `value`). Crossfades
   *  over `fade` from whatever plays — a one-shot included (it is cut short). `stop()` removes it. */
  playLoop(def: LoopDef, options: LoopOptions = {}): Loop | undefined { return this._c.loop(this._c.layers[0], def, options) }
  /** Fade everything out, on every layer (loops included) → rest pose. */
  stop(options: StopOptions = {}): this { this._c.stopAll(options.fade ?? 0); return this }
  /** The base layer's current loop (the object the last `playLoop()` returned), if any. */
  get loop(): Loop | undefined { return this._c.layers[0].loop?.view }
  /** A one-shot on the base layer hasn't handed over yet. */
  get busy(): boolean { return this._c.busy(this._c.layers[0]) }
  /** Every clip shown this frame, all layers, with its weight — the crossfades' state (a debug overlay's list). */
  get active(): ActiveClip[] { return this._c.layers.flatMap((L) => this._c.active(L)) }

  // ---- layers ----
  /** A new layer on top (masked override / additive). The returned object is its handle. */
  addLayer(options: LayerOptions = {}): Layer { return this._c.addLayer(options) }

  // ---- clip events (`clip.addEvent(0.4, 'hit')`) ----
  on(event: string, cb: ClipEventHandler): this { this._c.on(event, cb); return this }
  off(event: string, cb: ClipEventHandler): this { this._c.off(event, cb); return this }

  // ---- props ----
  /** Global playback rate: 0.3 = slow-mo, 0 = pause. */
  get speed(): number { return this._c.speed }
  set speed(v: number) { this._c.setSpeed(v) }
  /** Root motion: the root bone's horizontal travel comes OFF the pose and moves the node instead —
   *  or its CharacterController (on this node or an ancestor) as a velocity, so it collides. For
   *  clips whose hips actually travel (Mixamo without "In Place", lunges, rolls). */
  get rootMotion(): boolean { return this._rootMotion }
  set rootMotion(on: boolean) { this._rootMotion = on; this._c.setRootMotion(on, this._rootRotation) }
  /** With `rootMotion`, the root bone's TURN (its yaw about the node's up, since the clip's first
   *  frame) is root motion too: it comes off the pose and turns the node the travel lands on — the
   *  character (or the model) faces where a turn-start / turn-in-place / arc clip took it, and the
   *  crossfade into the next clip keeps that heading instead of swinging back. Off by default: a
   *  walk cycle's hip sway is a turn too, and most rigs want it in the pose; turn it on for a rig
   *  whose root bone carries the heading (`lecodes assets retarget --root-rotation yaw`). */
  get rootRotation(): boolean { return this._rootRotation }
  set rootRotation(on: boolean) { this._rootRotation = on; if (this._rootMotion) this._c.setRootMotion(true, on) }
}
