// Sprite sheets as data: one `.sprite.ts` file per image, `export default defineSpriteSheet({...})`.
// ONE model: every named entry is a region of the image (a rect), optionally SLICED into an
// animation grid, optionally carrying named clips over that grid — so game code (and the scene
// editor) reference frames by NAME and never hardcode rects:
//
//   import props from './props.sprite'
//   import hero from './hero.sprite'
//   scene.add(props.make('tree_orange', { position: [80, 144], layer: 1 }))
//   scene.add(props.make('water', { position: [0, 0] }))            // sliced, loops on its own
//   const player = hero.make('hero', { clip: 'idle', layer: 1 })
//   player.anim.play('walk', inputVector)
//
// A sheet describes ART only — rects, slices, clips, anchors. World semantics (colliders, bodies)
// are the SCENE's job: attach Shape2D / Physics2D / Trigger2D aspects to the placed node (a
// `.scene2d.ts` `aspects:` list, or `.aspect(...)` in code). Each tool owns its own part.
//
// The texture loads lazily on the first make()/load(); sprites created before it resolves get their
// texture/frame applied on arrival (the world size is known up front from the rect / slice cell, so
// layout doesn't wait). The sprite editor reads and writes these files — hand edits are preserved
// (see packages/scene-doc).

import { type Vec2Like } from "../math/vec"
import { Sprite } from "./Sprite"
import { SpriteAnimation, type Clip } from "./SpriteAnimation"
import { Texture2D } from "./Texture2D"

/**
 * A named region of the image. Just a rect is a static sprite; `slice` subdivides the rect into an
 * animation grid ([cols, rows] — the cell is rect size / counts); `clips` names frame runs over
 * that grid (local, row-major). A sliced entry with NO clips loops all of its cells in order.
 */
export type SheetSprite = {
  /** Pixel rect [x, y, w, h] in the image. */
  rect?: [number, number, number, number]
  /** Subdivide the rect into an animation grid: [cols, rows]. */
  slice?: [number, number]
  /** Playback fps for this sprite's animation (defaults to the sheet fps, then 12). */
  fps?: number
  /** Named clips over the local grid (see SpriteAnimation). Omit to loop every cell. */
  clips?: Record<string, Clip>
  /** Facing names in local-row order for `perDirection` clips (compass tokens: S/SE/E/…). */
  directions?: string[]
  /** Overrides the sheet anchor. */
  anchor?: Vec2Like
}

export type SpriteSheetDef = {
  /** The image URL — write `asset('./sheet.png')`. */
  image: string
  /** Default pivot for every sprite made from this sheet ([0.5, 1] = feet, for Y-sorted worlds). */
  anchor?: Vec2Like
  /** Default fps for sliced sprites / clips that don't override it. */
  fps?: number
  /** The named sprites. */
  sprites?: Record<string, SheetSprite>
}

export type SpriteMakeOptions = {
  position?: Vec2Like
  layer?: number
  /** Overrides the sheet/sprite anchor. */
  anchor?: Vec2Like
  /** Start this clip immediately (sliced sprites with clips). */
  clip?: string
  /** Initial facing for `perDirection` clips. */
  direction?: string
}

/** The name a sliced-but-clipless sprite's implicit everything-loop plays under. */
const LOOP_ALL = "all"

export class SpriteSheet {
  private _def: SpriteSheetDef
  private _tex: Texture2D | null = null
  private _loading: Promise<Texture2D> | null = null

  constructor(def: SpriteSheetDef) {
    this._def = def
  }

  /** The sheet definition (read-only by convention — the editor owns the file). */
  get def(): SpriteSheetDef { return this._def }

  /** The loaded texture, or null before load() resolves. */
  get texture(): Texture2D | null { return this._tex }

  /** Load the sheet's texture (idempotent). make() starts this automatically. */
  load(): Promise<Texture2D> {
    if (!this._loading) {
      this._loading = Texture2D.load(this._def.image).then((tex) => { this._tex = tex; return tex })
    }
    return this._loading
  }

  /** Names of the sheet's sprites. */
  get spriteNames(): string[] { return Object.keys(this._def.sprites ?? {}) }

  /** Build a named sprite; starting a clip hands back the animation handle. */
  make(name: string, opts: SpriteMakeOptions & { clip: string }): Sprite & { anim: SpriteAnimation }
  make(name: string, opts?: SpriteMakeOptions): Sprite
  make(name: string, opts: SpriteMakeOptions = {}): Sprite {
    const def = this._def
    const named = def.sprites?.[name]
    if (!named) {
      throw new Error(`SpriteSheet.make(): unknown sprite "${name}" (have: ${this.spriteNames.join(", ") || "none"})`)
    }
    const rect = named.rect
    const slice = named.slice

    const sprite = new Sprite({
      anchor: opts.anchor ?? named.anchor ?? def.anchor,
      layer: opts.layer,
      position: opts.position,
    })

    // The world size is known without the texture (the rect, or its slice cell) — set it up front
    // so layout, Y-sort and colliders are correct even while the image is still loading.
    if (rect) sprite.size = slice ? [ rect[2] / slice[0], rect[3] / slice[1] ] : [ rect[2], rect[3] ]

    // sliced sprite: all animation flows through SpriteAnimation, anchored at the rect's origin.
    // No clips declared = one implicit clip looping every cell (animated tiles).
    if (rect && slice) {
      const [ cols, rows ] = slice
      const clips = named.clips && Object.keys(named.clips).length > 0
        ? named.clips
        : { [LOOP_ALL]: Array.from({ length: cols * rows }, (_, i) => i) }
      const animOpts: Partial<SpriteAnimation> = {
        size: [ rect[2] / cols, rect[3] / rows ],
        cols,
        origin: [ rect[0], rect[1] ],
        clips,
      }
      const fps = named.fps ?? def.fps
      if (fps !== undefined) animOpts.fps = fps
      if (named.directions) animOpts.directions = named.directions
      sprite.aspect(SpriteAnimation, animOpts)
      const start = opts.clip ?? (named.clips ? undefined : LOOP_ALL)
      if (start) (sprite as Sprite & { anim: SpriteAnimation }).anim.play(start, opts.direction)
    }

    // texture + frame: now if loaded, else on arrival
    if (this._tex) this._applyTexture(sprite, named, this._tex)
    else this.load().then((tex) => this._applyTexture(sprite, named, tex))
    return sprite
  }

  /** The pixel rect [x, y, w, h] of a named sprite's REGION (the whole rect, sliced or not). */
  rectOf(name: string): [number, number, number, number] | null {
    const rect = this._def.sprites?.[name]?.rect
    return rect ? [ ...rect ] : null
  }

  private _applyTexture(sprite: Sprite, named: SheetSprite, tex: Texture2D): void {
    sprite.texture = tex // native resets UV/size; the Sprite setter re-applies our explicit size
    const rect = named.rect
    if (rect) {
      const [ cw, ch ] = named.slice ? [ rect[2] / named.slice[0], rect[3] / named.slice[1] ] : [ rect[2], rect[3] ]
      sprite.setFramePx(rect[0], rect[1], cw, ch) // cell 0 (or the whole static rect)
    }
    const anim = (sprite as Partial<{ anim: SpriteAnimation }>).anim
    if (anim) anim.define()
  }
}

/** Declare a sprite sheet (the default export of a `.sprite.ts` file). */
export const defineSpriteSheet = (def: SpriteSheetDef): SpriteSheet => new SpriteSheet(def)
