// The engine-facing core behind Animator / Layer / Blend / Playback (docs/animator-plan.md). NOT exported
// from the SDK: the public classes are thin views over the records here. The engine runs the whole
// per-frame loop (fades, blend spaces, one-shot fade-back, root motion); this file binds clips,
// issues play/stop/blend calls and routes the engine's slot events back to playbacks.

import { AnimationClip } from "./AnimationClip"
import { Loop } from "./Loop"
import { Layer } from "./Layer"
import { Playback } from "./Playback"
import type { Node } from "../Node"

/** A position on a blend axis (1D) or plane (2D). */
export type BlendPosition = number | readonly [number, number]

export type PlayOptions = {
  /** Crossfade seconds for BOTH ends (default 0 = cut). */
  fade?: number
  /** Fade-in seconds (overrides `fade`). */
  fadeIn?: number
  /** One-shots: fade-back seconds at the END (overrides `fade`). The return fade starts this long
   *  BEFORE the clip ends, while it still plays — no freeze-frame on the last pose. On a layer with
   *  no blend to return to, giving `fadeOut` releases the layer at the end instead of holding the
   *  last frame. */
  fadeOut?: number
  /** Playback rate for this clip (default 1). */
  speed?: number
  /** Rewind even if the clip is already playing (default: rewind only if it isn't). */
  restart?: boolean
}

export type StopOptions = { fade?: number }

export type LoopOptions = {
  /** Crossfade seconds from whatever the layer shows — the previous loop, or a one-shot still playing
   *  (default 0 = cut). */
  fade?: number
  /** Playback rate of the loop's clips (default 1). */
  speed?: number
  /** Where in the cycle to start, 0–1 (default 0): a blend's members share one clock, so this is the
   *  phase of every member — e.g. the phase whose pose matches the clip the loop takes over from. */
  phase?: number
}
/** What a layer loops: one clip (name / index), or a blend space `{ name: position }`. */
export type LoopDef = string | number | Record<string, BlendPosition>

/** One clip the layer shows this frame — `layer.active` / `anim.active` (a debug overlay's list). */
export type ActiveClip = {
  name: string
  clip: AnimationClip
  /** contribution to the pose, 0–1 — the crossfades' state */
  weight: number
  /** clock (s) */
  time: number
  /** a member of the layer's loop (else a one-shot — playing, or fading after its hand-over) */
  loop: boolean
}

export type LayerOptions = {
  /** Bone name(s): the layer drives only these subtrees (`'Spine1'` = upper body). Default: the whole rig. */
  mask?: string | string[]
  /** Blend each clip's DELTA from its own first frame on top of the layers below (lean, breathe, recoil)
   *  instead of overriding them. */
  additive?: boolean
  /** Layer contribution 0–1 (default 1). */
  weight?: number
}

export type ClipEventHandler = (clip: string, layer: Layer) => void

// ---- records -------------------------------------------------------------------------------------

/** One INSTANCE of a clip on a layer (an engine slot). A clip has as many as it needs at once: a
 *  clip started again while its last instance still fades gets another slot — the old one keeps
 *  fading beneath (a rewind in place would snap the pose). `playback` = the one-shot that owns it
 *  until its hand-over; `last` = the one that took it most recently (its weight / time after that). */
export type SlotRec = { name: string, clip: AnimationClip, slot: number, layer: LayerRec, inBlend: boolean, playback?: PlaybackRec, last?: PlaybackRec }
export type PlaybackRec = { slot: SlotRec, playing: boolean, done: Promise<boolean>, resolve: (natural: boolean) => void }
export type LayerRec = { index: number, view: Layer, slots: SlotRec[], loop?: LoopRec, weight: number, mask: string, additive: boolean }
export type LoopRec = { layer: LayerRec, members: { name: string, slot: SlotRec }[], value: BlendPosition, view: Loop, dead: boolean }

const handlers = new Map<number, (slot: number, type: number) => void>()
let eventsInstalled = false
const ensureEvents = (): void => {
  if (eventsInstalled) return
  eventsInstalled = true
  _creator.setOnAnimatorEvent((animatorId, slot, type) => handlers.get(animatorId)?.(slot, type))
}

const settled = (slot: SlotRec): PlaybackRec => {
  let resolve = (_n: boolean): void => {}
  const done = new Promise<boolean>((r) => { resolve = r })
  resolve(false)
  return { slot, playing: false, done, resolve }
}

export class Core {
  id = 0
  /** Added clips by name (`addClip`) — the lookup index; an added name overrides an embedded one. */
  readonly table = new Map<string, AnimationClip>()
  readonly layers: LayerRec[] = []
  speed = 1
  private _embedded?: AnimationClip[]
  private readonly _added: { name: string, clip: AnimationClip }[] = []
  private _list?: AnimationClip[]
  private readonly _listeners = new Map<string, Set<ClipEventHandler>>()

  readonly node: Node
  constructor(node: Node) { this.node = node; this.layers.push(this.newLayer()) }

  // ---- clips ----
  embedded(): AnimationClip[] {
    if (!this._embedded) {
      this._embedded = AnimationClip._ofSet(_creator.getGlbClipSet(this.node.id))
      const seen = new Set<string>()
      for (const c of this._embedded) {
        if (seen.has(c.name)) console.warn(`Animator: ${this.node.name || "the node"} has several clips named '${c.name}' — by name you get the first; reach the others by index (anim.clip(n))`)
        seen.add(c.name)
      }
    }
    return this._embedded
  }
  /** The ordered clip list: the GLB's clips in file order (an added clip of the same name takes the
   *  embedded one's place), then the remaining added clips in insertion order. Cached until addClip. */
  clips(): AnimationClip[] {
    if (this._list) return this._list
    const out: AnimationClip[] = []
    const placed = new Set<string>()
    for (const c of this.embedded()) {
      const override = this.table.get(c.name)
      if (override && !placed.has(c.name)) { out.push(override); placed.add(c.name) }
      else out.push(c)
    }
    for (const a of this._added) if (!placed.has(a.name)) { out.push(a.clip); placed.add(a.name) }
    return this._list = out
  }
  addClip(name: string, clip: AnimationClip): void {
    const i = this._added.findIndex((a) => a.name === name)
    if (i >= 0) this._added[i] = { name, clip }
    else this._added.push({ name, clip })
    this.table.set(name, clip)
    this._list = undefined
  }
  /** A clip by name (added first, then embedded), or by index into `clips()` (none = the first). */
  resolveClip(ref: string | number | undefined): [string, AnimationClip] | undefined {
    if (ref === undefined || typeof ref === "number") { const c = this.clips()[ref ?? 0]; return c ? [ c.name, c ] : undefined }
    const c = this.table.get(ref) ?? this.embedded().find((e) => e.name === ref)
    return c ? [ ref, c ] : undefined
  }
  private clipNames(): string { return this.clips().map((c) => c.name).join(", ") || "(empty)" }

  // ---- lifecycle ----
  /** Create the native animator on demand. False = nothing to animate (warned). */
  ensure(): boolean {
    if (this.id) return true
    if (this.embedded().length === 0 && this.table.size === 0) { console.warn(`Animator: no clips on ${this.node.name || "the node"} — nothing to play`); return false }
    ensureEvents()
    this.id = _creator.animatorCreate(this.node.id)
    if (!this.id) { console.warn("Animator: node has no transform hierarchy"); return false }
    handlers.set(this.id, (slot, type) => this.onEvent(slot, type))
    if (this.speed !== 1) _creator.animatorSetGlobal(this.id, this.speed, false)
    for (const L of this.layers) if (L.index > 0) this.pushLayer(L)
    return true
  }
  destroy(): void {
    for (const L of this.layers) for (const s of L.slots) if (s.playback) this.settle(s.playback, false)
    if (this.id) { _creator.animatorDestroy(this.id); handlers.delete(this.id); this.id = 0 }
    for (const L of this.layers) { L.slots = []; if (L.loop) { L.loop.dead = true; L.loop = undefined } }
  }

  // ---- layers ----
  newLayer(options?: LayerOptions): LayerRec {
    const rec: LayerRec = { index: this.layers.length, view: undefined as unknown as Layer, slots: [], weight: options?.weight ?? 1, mask: "", additive: options?.additive ?? false }
    if (options?.mask !== undefined) rec.mask = Array.isArray(options.mask) ? options.mask.join("\n") : options.mask
    rec.view = new Layer(rec, this)
    return rec
  }
  addLayer(options: LayerOptions): Layer {
    if (this.layers.length >= 255) { console.warn("Animator: too many layers"); return this.layers[this.layers.length - 1].view }
    const rec = this.newLayer(options)
    this.layers.push(rec)
    if (this.id) this.pushLayer(rec)
    return rec.view
  }
  pushLayer(L: LayerRec): void {
    if (!this.id) return
    for (const m of L.mask.split("\n")) if (m && !this.node.bone(m)) console.warn(`Animator: layer mask bone '${m}' not found under ${this.node.name || "the node"}`)
    _creator.animatorSetLayer(this.id, L.index, L.weight, L.additive, L.mask)
  }

  // ---- slots ----
  /** A slot for the clip on the layer: one of its own that is silent now (never shown, or faded
   *  out), else a new binding — so a clip still fading can start again OVER itself. */
  slotFor(L: LayerRec, name: string, clip: AnimationClip): SlotRec | undefined {
    const have = L.slots.find((s) => s.name === name && s.clip === clip && !s.inBlend && !s.playback && this.slotWeight(s) <= 0)
    if (have) return have
    const slot = _creator.animatorBind(this.id, clip._set, clip._index, L.index)
    if (slot < 0) { console.warn(`Animator: could not bind clip '${name}'`); return undefined }
    const bound = _creator.animatorBoundTracks(this.id, slot)
    if (clip.trackCount > 0 && bound === 0) console.warn(`Animator: '${name}' — no track matched a node name under ${this.node.name || "the node"} (different rig?)`)
    else if (bound < clip.trackCount) console.warn(`Animator: '${name}' — ${clip.trackCount - bound}/${clip.trackCount} tracks unbound (bone names not found)`)
    const rec: SlotRec = { name, clip, slot, layer: L, inBlend: false }
    L.slots.push(rec)
    return rec
  }

  // ---- play / stop ----
  play(L: LayerRec, ref: string | number | undefined, o: PlayOptions): Playback {
    const found = this.resolveClip(ref)
    if (!found) {
      console.warn(`Animator: no clip ${ref === undefined ? "to play" : JSON.stringify(ref)} on ${this.node.name || "the node"} — table: ${this.clipNames()}`)
      return new Playback(settled({ name: "", clip: AnimationClip._none(), slot: -1, layer: L, inBlend: false }), this)
    }
    const [name, clip] = found
    if (!this.ensure()) return new Playback(settled({ name, clip, slot: -1, layer: L, inBlend: false }), this)
    const s = this.slotFor(L, name, clip)
    if (!s) return new Playback(settled({ name, clip, slot: -1, layer: L, inBlend: false }), this)
    if (s.inBlend) { console.warn(`Animator: '${name}' is part of this layer's loop — use playLoop() to change what it rests on`); return new Playback(settled(s), this) }
    const fadeIn = o.fadeIn ?? o.fade ?? 0
    const fadeOut = o.fadeOut ?? o.fade ?? -1
    // the other one-shots on this layer are cut short (their promises resolve false)
    for (const other of L.slots) if (other.playback) this.settle(other.playback, false)
    let resolve = (_n: boolean): void => {}
    const done = new Promise<boolean>((r) => { resolve = r })
    const rec: PlaybackRec = { slot: s, playing: true, done, resolve }
    s.playback = rec; s.last = rec
    _creator.animatorPlay(this.id, s.slot, false, o.speed ?? 1, fadeIn, fadeOut, o.restart ?? false)
    return new Playback(rec, this)
  }
  /** A one-shot on the layer hasn't handed over yet. */
  busy(L: LayerRec): boolean { return L.slots.some((s) => s.playback?.playing) }
  settle(p: PlaybackRec, natural: boolean): void {
    p.playing = false
    if (p.slot.playback === p) p.slot.playback = undefined
    p.resolve(natural)
  }
  stopSlot(s: SlotRec, fade: number): void {
    if (s.playback) this.settle(s.playback, false)
    if (this.id && s.slot >= 0) _creator.animatorStop(this.id, s.layer.index, s.slot, fade)
  }
  stopLayer(L: LayerRec, fade: number): void {
    for (const s of L.slots) if (s.playback) this.settle(s.playback, false)
    if (L.loop) { L.loop.dead = true; L.loop = undefined }
    if (this.id) _creator.animatorStop(this.id, L.index, -1, fade)
  }
  stopAll(fade: number): void { for (const L of this.layers) this.stopLayer(L, fade) }

  // ---- loop ----
  loop(L: LayerRec, def: LoopDef, o: LoopOptions): Loop | undefined {
    if (!this.ensure()) return undefined
    const fade = o.fade ?? 0
    if (L.loop) { L.loop.dead = true; for (const m of L.loop.members) m.slot.inBlend = false; L.loop = undefined }
    for (const s of L.slots) if (s.playback) this.settle(s.playback, false)   // one-shots on the layer are cut short
    const space: Record<string, BlendPosition> = typeof def === "object" ? def : {}
    if (typeof def !== "object") {
      const found = this.resolveClip(def)
      if (!found) { console.warn(`Animator: no clip ${JSON.stringify(def)} to loop — table: ${this.clipNames()}`); return undefined }
      space[found[0]] = 0
    }
    const rec: LoopRec = { layer: L, members: [], value: 0, view: undefined as unknown as Loop, dead: false }
    rec.view = new Loop(rec, this)
    const slots: number[] = [], positions: number[] = []
    const is2D = Object.values(space).some((p) => typeof p !== "number")
    for (const [name, at] of Object.entries(space)) {
      const found = this.resolveClip(name)
      if (!found) { console.warn(`Animator: loop clip '${name}' is not in the clip table`); continue }
      const s = this.slotFor(L, name, found[1])
      if (!s) continue
      if (s.playback) this.settle(s.playback, false)
      s.inBlend = true
      rec.members.push({ name, slot: s })
      slots.push(s.slot)
      if (is2D) positions.push(typeof at === "number" ? at : at[0], typeof at === "number" ? 0 : at[1])
      else positions.push(at as number)
    }
    L.loop = rec
    _creator.animatorSetBlend(this.id, L.index, is2D ? 2 : 1, new Uint16Array(slots), new Float32Array(positions), fade, o.speed ?? 1)
    if (o.phase !== undefined) this.setLoopPhase(rec, o.phase)
    return rec.view
  }
  /** The loop's phase through its cycle, 0–1 (the members share one clock). */
  loopPhase(b: LoopRec): number {
    const m = b.members[0]
    if (!m || b.dead) return 0
    const d = m.slot.clip.duration
    return d > 0 ? this.slotTime(m.slot) / d : 0
  }
  setLoopPhase(b: LoopRec, phase: number): void {
    const m = b.members[0]
    if (!m || b.dead) return
    this.seek(m.slot, (((phase % 1) + 1) % 1) * m.slot.clip.duration)   // seeking one member moves the group
  }
  setLoopValue(b: LoopRec, v: BlendPosition): void {
    if (b.dead) { console.warn("Animator: this loop was replaced — use the object the latest playLoop() returned"); return }
    b.value = v
    if (!this.id) return
    const [x, y] = typeof v === "number" ? [ v, 0 ] : [ v[0], v[1] ]
    _creator.animatorSetBlendValue(this.id, b.layer.index, x, y)
  }
  /** What the layer shows this frame: every instance with a weight, in binding order (stable rows). */
  active(L: LayerRec): ActiveClip[] {
    const out: ActiveClip[] = []
    for (const s of L.slots) {
      const weight = this.slotWeight(s)
      if (weight <= 0) continue
      out.push({ name: s.name, clip: s.clip, weight, time: this.slotTime(s), loop: s.inBlend })
    }
    return out
  }
  slotWeight(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotWeight(this.id, s.slot) : 0 }
  slotTime(s: SlotRec): number { return this.id && s.slot >= 0 ? _creator.animatorGetSlotTime(this.id, s.slot) : 0 }
  seek(s: SlotRec, time: number): void { if (this.id && s.slot >= 0) _creator.animatorSeek(this.id, s.slot, time) }

  // ---- props ----
  setSpeed(v: number): void { this.speed = v; if (this.id) _creator.animatorSetGlobal(this.id, v, false) }
  setRootMotion(on: boolean, rotation = false): void { if (this.ensure()) _creator.animatorSetRootMotion(this.id, on ? "*" : "", (on ? 2 : 0) | (on && rotation ? 4 : 0)) }

  // ---- events ----
  on(event: string, cb: ClipEventHandler): void { (this._listeners.get(event) ?? this._listeners.set(event, new Set()).get(event)!).add(cb) }
  off(event: string, cb: ClipEventHandler): void { this._listeners.get(event)?.delete(cb) }
  /** Native slot event: 0 completed / 1 loop / 2 settled (faded to 0) / 3 hand-over / 4+i clip event i. */
  private onEvent(slotIndex: number, type: number): void {
    let s: SlotRec | undefined
    for (const L of this.layers) { s = L.slots.find((x) => x.slot === slotIndex); if (s) break }
    if (!s) return
    if (type >= 4) {
      const name = s.clip._events[type - 4]?.name
      const cbs = name ? this._listeners.get(name) : undefined
      if (cbs) for (const cb of [ ...cbs ]) cb(s.name, s.layer.view)
      return
    }
    const p = s.playback
    if (!p) return
    if (type === 3) this.settle(p, true)          // hand-over: the clip is over; the app's next call redirects the fade
    else if (type === 2) this.settle(p, false)    // faded out after a stop / replacement before handing over
  }
}
