// Sprite-sheet animation as an aspect on a Sprite. Configured as DATA (frame size + named clips) in
// the attach call, then driven by name:
//   sprite.aspect(SpriteAnimation, { size: [32, 48], fps: 10, clips: { idle: [0,1], walk: [4,5,6,7] } })
//   sprite.anim.play('walk')
// Once defined, clips advance entirely in native code; the aspect just translates clip ids back to
// names for the node's 'loopReached' / 'completed' events.
//
// Directional sheets (one texture row per facing): set `directions` in row order and mark clips
// `perDirection` — each expands to `name_DIR` with the frames offset by that row. Then
// `play('walk', 'SE')` or `play('walk', moveVector)` picks the row (compass tokens: E/NE/N/NW/W/SW/S/SE),
// and the last direction sticks, so a later `play('idle')` keeps facing.
//
// The native clips need the texture's dimensions, so when the Sprite has no texture yet (SpriteSheet's
// deferred load), the define is postponed: play() calls queue by name and start once define() runs.

import { Aspect } from "../core/Aspect"
import { cx, cy, type Vec2Like } from "../math/vec"
import { ensureAnimEvents } from "./loop"
import type { Sprite } from "./Sprite"

/** Per-clip frame list, or an object for per-clip fps/loop/direction overrides. */
export type Clip = number[] | { frames: number[], fps?: number, loop?: boolean, perDirection?: boolean }

// Compass angles in Y-up world degrees (E = +x, N = +y) for vector → direction resolution.
const COMPASS: Record<string, number> = { E: 0, NE: 45, N: 90, NW: 135, W: 180, SW: 225, S: 270, SE: 315 }

export class SpriteAnimation extends Aspect<"anim", Sprite> {
  static readonly aspect = "anim"

  /** Grid cell size in texture pixels (defaults to the full texture). */
  size?: [number, number]
  /** Grid columns override — for sheets with padding; defaults to floor(texWidth / cellWidth). */
  cols?: number
  /** Pixel origin of the grid in the texture — cells index from here (a SpriteSheet region). */
  origin?: [number, number]
  /** Default fps for clips that don't override it. */
  fps = 12
  /** Default loop for clips that don't override it. */
  loop = true
  /** Named clips; frame indices are row-major into the grid. Set via the attach opts. */
  clips: Record<string, Clip> = {}
  /** Facing names in texture-row order (row i = directions[i]) for `perDirection` clips. */
  directions?: string[]

  private _clipIds: Record<string, number> = {}
  private _clipNames: Record<number, string> = {}
  private _current: string | null = null
  private _defined = false
  private _queued: string | null = null
  private _dir: string | null = null

  onAttach(): void {
    // No texture yet (deferred sheet load): postpone the native define — SpriteSheet (or the user)
    // calls define() after assigning the texture; play() calls queue until then.
    if (this.node.texture) this._define()
    // route native animation events for this entity to named 'loopReached'/'completed' events.
    ;(this.node as { _dispatchAnimEvent: (clipId: number, type: number) => void })._dispatchAnimEvent =
      (clipId, type) => this.node._emitAnim(type === 1 ? "completed" : "loopReached", this._clipNames[clipId] ?? "")
    ensureAnimEvents()
  }

  /** (Re)build native clips from `this.clips`. Called on attach; call again after changing clips. */
  define(): this { this._define(); return this }

  private _define(): void {
    const node = this.node
    const tex = node.texture
    if (!tex) throw new Error("SpriteAnimation: set a texture on the Sprite before defining clips")
    const [ fw, fh ] = this.size ?? [ tex.width, tex.height ]
    const cols = this.cols ?? Math.max(1, Math.floor(tex.width / fw))
    const [ ox, oy ] = this.origin ?? [ 0, 0 ]

    const defineOne = (name: string, indices: number[], fps: number, loop: boolean): void => {
      const frames = new Float32Array(indices.length * 4)
      indices.forEach((idx, i) => {
        const px = ox + (idx % cols) * fw
        const py = oy + Math.floor(idx / cols) * fh
        frames[i * 4 + 0] = px / tex.width
        frames[i * 4 + 1] = py / tex.height
        frames[i * 4 + 2] = (px + fw) / tex.width
        frames[i * 4 + 3] = (py + fh) / tex.height
      })
      const clipId = _creator2d.defineAnimation(node.id, frames, fps, loop)
      this._clipIds[name] = clipId
      this._clipNames[clipId] = name
    }

    for (const name of Object.keys(this.clips)) {
      const value = this.clips[name]
      const indices = Array.isArray(value) ? value : value.frames
      const fps = Array.isArray(value) ? this.fps : (value.fps ?? this.fps)
      const loop = Array.isArray(value) ? this.loop : (value.loop ?? this.loop)
      const dirs = !Array.isArray(value) && value.perDirection ? this.directions : undefined

      if (dirs && dirs.length > 0) {
        // one clip per facing: row i's frames are the base (row-0) frames shifted down i rows.
        dirs.forEach((dir, row) => defineOne(`${name}_${dir}`, indices.map((f) => f + row * cols), fps, loop))
      } else {
        defineOne(name, indices, fps, loop)
      }
    }
    if (this.size) _creator2d.setSpriteSize(node.id, fw, fh)
    this._defined = true

    if (this._queued) {
      const queued = this._queued
      this._queued = null
      const id = this._clipIds[queued]
      if (id === undefined) throw new Error(`SpriteAnimation.play(): unknown clip "${queued}"`)
      _creator2d.playAnimation(node.id, id)
    }
  }

  /**
   * Play a clip by name. Re-playing the active clip is a no-op (keeps it running smoothly).
   * For a `perDirection` clip, `dir` picks the facing — a direction name or a movement vector
   * (nearest compass row wins); omitted, the last direction (or the first row) is kept.
   */
  play(name: string, dir?: string | Vec2Like): this {
    let target = name
    const clip = this.clips[name]
    if (clip && !Array.isArray(clip) && clip.perDirection && this.directions?.length) {
      const resolved = this._resolveDir(dir)
      if (resolved) { this._dir = resolved; target = `${name}_${resolved}` }
    }
    if (this._current === target) return this
    this._current = target
    if (!this._defined) { this._queued = target; return this }
    const id = this._clipIds[target]
    if (id === undefined) throw new Error(`SpriteAnimation.play(): unknown clip "${target}"`)
    _creator2d.playAnimation(this.node.id, id)
    return this
  }

  stop(): this {
    this._current = null
    this._queued = null
    if (this._defined) _creator2d.stopAnimation(this.node.id)
    return this
  }

  /** Current facing (last direction resolved by play()), or null before the first directional play. */
  get direction(): string | null { return this._dir }

  set speed(value: number) { _creator2d.setAnimationSpeed(this.node.id, value) }
  get current(): string | null { return this._current }
  get frame(): number { return _creator2d.getAnimationFrame(this.node.id) }

  // A direction name passes through; a vector resolves to the nearest compass row this sheet has.
  // A zero vector (standing still) keeps the current facing.
  private _resolveDir(dir?: string | Vec2Like): string | null {
    const dirs = this.directions!
    if (typeof dir === "string") return dir
    if (dir !== undefined) {
      const x = cx(dir), y = cy(dir)
      if (x !== 0 || y !== 0) {
        const angle = (Math.atan2(y, x) * 180 / Math.PI + 360) % 360
        let best: string | null = null
        let bestDist = Infinity
        for (const d of dirs) {
          const a = COMPASS[d]
          if (a === undefined) continue
          const dist = Math.min(Math.abs(a - angle), 360 - Math.abs(a - angle))
          if (dist < bestDist) { bestDist = dist; best = d }
        }
        if (best) return best
      }
    }
    return this._dir ?? dirs[0] ?? null
  }
}
