// AnimationClip — the unit of the animation system: immutable data (name, duration, tracks that
// target bone NAMES, not entities). One representation for a GLB's embedded clips (model.anim.clips),
// clips loaded from other files (Mixamo: one GLB per animation) and procedural clips built from
// curves. An Animator binds clips to a skeleton by name. Clip EVENTS (`addEvent`) live here too —
// a fact about the clip's timeline, shared wherever it plays. See docs/animator-plan.md.
//
//   const [hero, idle, run] = await Promise.all([
//     Model.load(asset('./hero.glb')),
//     AnimationClip.load(asset('./idle.glb')),            // the file's only/first clip
//     AnimationClip.load(asset('./run.glb')),
//   ])
//   const slash = await AnimationClip.load(asset('./attacks.glb'), 'Slash')   // one out of many
//   const bob = AnimationClip.from({ tracks: { Hips: { position: [[0, [0,0,0]], [1, [0,0.05,0]]] } } })

import { fetch, type FetchResponse } from "../../runtime/fetch"
import type { Vec3Like } from "../../math/vec"
import type { QuatLike } from "../../math/quat"

/** A keyframe: `[time (s), value]`. */
export type ClipKey<V> = [number, V]
export type ClipInterpolation = "linear" | "step"

/** Curves for one target node (bone name), any subset of the three channels. */
export type ClipTrackDef = {
  position?: ClipKey<Vec3Like | [number, number, number]>[]
  rotation?: ClipKey<QuatLike | [number, number, number, number]>[]
  scale?: ClipKey<Vec3Like | [number, number, number]>[]
  interpolation?: ClipInterpolation
}

export type ClipDef = {
  /** Clip length in seconds — defaults to the last key time across all tracks. */
  duration?: number
  /** Target node name → its curves. Names bind to bones (or any child node) at Animator attach. */
  tracks: Record<string, ClipTrackDef>
}

export type ClipInfo = { name: string, duration: number, trackCount: number }

const PATH = { position: 0, rotation: 1, scale: 2 } as const
const INTERP = { linear: 0, step: 1 } as const

const setCache = new Map<number, AnimationClip[]>()

const readInfo = (setId: number): ClipInfo[] =>
  ((_creator.getClipSetInfo(setId) ?? []) as ClipInfo[]).map((c) => ({ name: c.name, duration: c.duration, trackCount: c.trackCount ?? 0 }))

const readValue = (v: unknown, comps: number, out: number[]): void => {
  if (Array.isArray(v)) { for (let i = 0; i < comps; i++) out.push(Number(v[i] ?? (i === 3 ? 1 : 0))); return }
  const o = v as Record<string, number>
  out.push(o.x ?? 0, o.y ?? 0, o.z ?? 0)
  if (comps === 4) out.push(o.w ?? 1)
}

export class AnimationClip {
  /** Source name (the clip's name inside its file; `"clip"` for procedural clips). Informational —
   *  an Animator addresses clips by the key YOU give it. */
  readonly name: string
  /** Length in seconds. */
  readonly duration: number
  /** Number of animated (node, channel) tracks. */
  readonly trackCount: number
  /** @internal native clip set + index inside it. */
  readonly _set: number
  /** @internal */
  readonly _index: number
  /** @internal events: seconds + name, sorted (the engine gets them normalized via setClipEvents). */
  _events: { t: number, name: string }[] = []

  private constructor(set: number, index: number, info: ClipInfo) {
    this._set = set
    this._index = index
    this.name = info.name
    this.duration = info.duration
    this.trackCount = info.trackCount
  }

  /** @internal — a placeholder for a Playback that never started (unknown clip). */
  static _none(): AnimationClip { return new AnimationClip(0, 0, { name: "", duration: 0, trackCount: 0 }) }

  /** @internal — wrap the clips of an existing native set (a GLB's embedded clips). Cached per set
   *  so every model / clone sharing the set gets the SAME objects (events added on one apply to all —
   *  as they do in the engine). */
  static _ofSet(setId: number): AnimationClip[] {
    if (!setId) return []
    let clips = setCache.get(setId)
    if (!clips) { clips = readInfo(setId).map((info, i) => new AnimationClip(setId, i, info)); setCache.set(setId, clips) }
    return clips
  }

  /** Mark a moment of the clip (SECONDS from its start) with an event name: `kick.addEvent(0.4, 'hit')`
   *  → `anim.on('hit', (clip, layer) => …)` fires when the playhead crosses it, loops included.
   *  Events are part of the clip: every model playing it gets them; `slice()` keeps the ones inside
   *  the range, re-timed. Chainable. */
  addEvent(time: number, name: string): this {
    this._events.push({ t: Math.min(Math.max(0, time), this.duration), name })
    this._events.sort((a, b) => a.t - b.t)
    this._pushEvents()
    return this
  }
  private _pushEvents(): void {
    const d = this.duration
    _creator.setClipEvents(this._set, this._index, new Float32Array(this._events.map((e) => (d > 0 ? e.t / d : 0))))
  }

  /** Load ONE clip from a GLB: the file's only/first clip, or the one named / at the given index. */
  static load(source: string | FetchResponse, clip?: string | number): Promise<AnimationClip> {
    return AnimationClip._loadSet(source).then(([setId, infos]) => {
      let index = 0
      if (typeof clip === "number") index = clip
      else if (typeof clip === "string") index = infos.findIndex((c) => c.name === clip)
      if (index < 0 || index >= infos.length) {
        _creator.destroyClipSet(setId)
        throw new Error(`AnimationClip.load: no clip ${JSON.stringify(clip)} in ${AnimationClip._label(source)} — clips: ${infos.map((c) => c.name).join(", ") || "(none)"}`)
      }
      return new AnimationClip(setId, index, infos[index])
    })
  }

  /** Load EVERY clip of a GLB (library files), keyed by clip name. */
  static loadAll(source: string | FetchResponse): Promise<Record<string, AnimationClip>> {
    return AnimationClip._loadSet(source).then(([setId, infos]) => {
      const out: Record<string, AnimationClip> = {}
      infos.forEach((info, i) => { out[info.name] = new AnimationClip(setId, i, info) })
      return out
    })
  }

  /** Build a clip from curves in code — no DCC needed. Keys are `[time, value]`; a track binds to the
   *  node of that name when the clip is used by an Animator (bones, or any child node). */
  static from(def: ClipDef): AnimationClip {
    const names: string[] = []
    const data: number[] = [0, 0]
    let trackCount = 0
    let duration = 0
    for (const [target, track] of Object.entries(def.tracks)) {
      const interp = INTERP[track.interpolation ?? "linear"] ?? 0
      const emit = (path: 0 | 1 | 2, comps: 3 | 4, keys: ClipKey<unknown>[] | undefined) => {
        if (!keys || keys.length === 0) return
        const sorted = keys.slice().sort((a, b) => a[0] - b[0])
        names.push(target)
        data.push(path, interp, comps, sorted.length)
        for (const k of sorted) data.push(k[0])
        for (const k of sorted) readValue(k[1], comps, data)
        duration = Math.max(duration, sorted[sorted.length - 1][0])
        trackCount++
      }
      emit(PATH.position, 3, track.position)
      emit(PATH.rotation, 4, track.rotation)
      emit(PATH.scale, 3, track.scale)
    }
    data[0] = trackCount
    if (def.duration !== undefined) duration = def.duration
    data[1] = duration
    const setId = _creator.createClipFromTracks(names.join("\n"), new Float32Array(data))
    if (setId === 0) throw new Error("AnimationClip.from: invalid track data")
    const info = readInfo(setId)[0] ?? { name: "clip", duration, trackCount }
    return new AnimationClip(setId, 0, info)
  }

  /** A sub-range of this clip as a NEW clip — `[start, end]` seconds of the source, re-timed to 0
   *  (`end` defaults to the clip's end; array-slice semantics). Cut a too-long take down to the
   *  action, or carve several sub-clips out of one packed timeline:
   *
   *    clips: { Kick: kick.slice(0.2, 1.1) }
   *
   *  Keys outside the range are dropped and exact boundary values are interpolated in, so the
   *  sliced clip starts and ends precisely on the source's pose at the cut points. Everything
   *  downstream (blend spaces, events' 0–1 times, root motion, phase sync) sees a normal clip of
   *  the new duration. */
  slice(start: number, end?: number): AnimationClip {
    const e = Math.min(end ?? this.duration, this.duration)
    const st = Math.max(0, start)
    const setId = _creator.sliceClip(this._set, this._index, st, e)
    if (setId === 0) throw new Error(`AnimationClip.slice: bad range ${start}${end !== undefined ? `–${end}` : ""} of '${this.name}' (${this.duration.toFixed(2)}s)`)
    const info = readInfo(setId)[0] ?? { name: this.name, duration: e - st, trackCount: this.trackCount }
    const out = new AnimationClip(setId, 0, info)
    // the engine re-timed the event TIMES; carry the names the same way
    out._events = this._events.filter((ev) => ev.t >= st - 1e-6 && ev.t <= e + 1e-6).map((ev) => ({ t: Math.min(Math.max(0, ev.t - st), e - st), name: ev.name }))
    return out
  }

  private static _label(source: string | FetchResponse): string {
    return typeof source === "string" ? source : "response"
  }

  private static _loadSet(source: string | FetchResponse): Promise<[number, ClipInfo[]]> {
    if (typeof _creator.loadClips !== "function") {
      return Promise.reject(new Error("AnimationClip.load: this host has no animation-clip support yet"))
    }
    if (typeof source === "string") {
      return fetch(source, { useOnce: true }).then((resp) => {
        if (resp.status >= 400) throw new Error(`AnimationClip.load: failed to load ${source}. HTTP ${resp.status}`)
        return AnimationClip._loadSet(resp)
      })
    }
    return new Promise<[number, ClipInfo[]]>((resolve, reject) => {
      _creator.loadClips((source as unknown as { _id: number })._id, (setId: number) => {
        if (setId === 0) { reject(new Error("AnimationClip.load: the file has no animations (or is not a GLB)")); return }
        resolve([setId, readInfo(setId)])
      }, reject)
    })
  }
}
