// Base 2D scene node — an empty transform. Sprite and Tilemap extend it, so every node operation
// (position, layer, visibility, events) works on them. Native (creator-2d) owns the authoritative
// transform. JS caches it for free reads (JS is normally the sole writer); the exception is a node
// driven by a dynamic/kinematic Physics2D body — there the native physics step also writes the
// transform, so such nodes are flagged `_physicsDriven` and their transform getters sync the cache
// from native first (lazy: only on read, only for moving bodies). So node.position stays correct
// under physics without any per-frame bookkeeping.
//
// Empty Node2Ds are useful as: grouping parents, world-space markers/spawn points, and physics /
// trigger objects — attach a Shape2D (geometry) plus a Physics2D (rigid body) or Trigger2D (sensor).

import { AspectHost } from "../core/Aspect"
import { Registry } from "../core/registry"
import { Vec2, cx, cy, type Vec2Like } from "../math/vec"
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
import { ensurePointerEvents } from "./touch"

// re-exported so existing `import { Vec2 } from "./Node2D"` sites keep working; the canonical home is math/.
export { Vec2, type Vec2Like }

export type Node2DEvents = {
  /** A looping animation clip wrapped around (fires each loop). */
  loopReached: (clip: string) => void
  /** A non-looping animation clip finished. */
  completed: (clip: string) => void
  /** A physics contact / sensor overlap began / ended (the other node). */
  enter: (other: Node2D) => void
  exit: (other: Node2D) => void
  /** Pointer up over this node's Physics2D shape (a tap/click). */
  click: (ev: ClickEvent<Node2D | null>) => void
  /** Pointer down on this node's Physics2D shape. Call ev.track(...) to capture the drag. */
  touchstart: (ev: TouchStartEvent<Node2D | null>) => void
}

/** id → Node2D, so host callbacks (animation events) route back to the owning JS object. */
export const node2dRegistry = new Registry<Node2D>()

export class Node2D extends AspectHost<Node2DEvents> {
  /** Native entity handle. */
  readonly id: number

  /** Optional debug/editor name (plain JS data — scene2d files stamp their node names here). */
  name?: string

  protected _x = 0
  protected _y = 0
  protected _rotation = 0
  protected _sx = 1
  protected _sy = 1
  protected _layer = 0
  protected _z = 0
  protected _visible = true

  /** @internal — set by Physics2D for a dynamic/kinematic body: the native step owns this node's
   *  transform, so the transform getters re-sync the cache from native before returning. */
  _physicsDriven = false

  private _parent: Node2D | null = null
  private _children: Node2D[] = []

  constructor(internalId?: number) {
    super()
    this.id = internalId ?? _creator2d.createEntity()
    node2dRegistry.set(this.id, this)
  }

  // --- transform (Y-up, LOCAL to the parent; at zoom 1, units == logical pixels) ---
  // Pull the native transform into the cache iff physics owns it. No-op for the common case (plain
  // nodes keep free reads); one native read for a moving body, and only when actually read.
  private _pull(): void { if (this._physicsDriven) this._syncLocalFromNative() }

  get x(): number { this._pull(); return this._x }
  set x(v: number) { this._pull(); this._x = v; _creator2d.setPosition(this.id, v, this._y) }
  get y(): number { this._pull(); return this._y }
  set y(v: number) { this._pull(); this._y = v; _creator2d.setPosition(this.id, this._x, v) }

  get position(): Vec2 { this._pull(); return new Vec2(this._x, this._y) }
  set position(v: Vec2Like) { this._x = cx(v); this._y = cy(v); _creator2d.setPosition(this.id, this._x, this._y) }

  /** Rotation in degrees (CCW). */
  get rotation(): number { this._pull(); return this._rotation }
  set rotation(deg: number) { this._rotation = deg; _creator2d.setRotation(this.id, deg) }

  get scale(): Vec2 { return new Vec2(this._sx, this._sy) }
  set scale(v: Vec2Like | number) {
    const sx = typeof v === "number" ? v : cx(v)
    const sy = typeof v === "number" ? v : cy(v)
    this._sx = sx; this._sy = sy
    _creator2d.setScale(this.id, sx, sy)
  }

  /** Draw layer (int). Higher layers render on top. */
  get layer(): number { return this._layer }
  set layer(v: number) { this._layer = v; _creator2d.setLayer(this.id, v) }

  /** Intra-layer depth for non-Y-sorted layers (higher z = on top). */
  get z(): number { return this._z }
  set z(v: number) { this._z = v; _creator2d.setZ(this.id, v) }

  get visible(): boolean { return this._visible }
  set visible(v: boolean) { this._visible = v; _creator2d.setVisible(this.id, v) }

  // --- hierarchy (parent/child; transforms above are LOCAL to the parent) ---
  get parent(): Node2D | null { return this._parent }
  set parent(p: Node2D | null) { this.setParent(p) }

  get children(): readonly Node2D[] { return this._children }

  /** Attach children to this node (each keeps its current world transform). Returns this. */
  add(...children: Node2D[]): this {
    for (const c of children) c.setParent(this)
    return this
  }

  /** Detach a direct child (reparents it to root, keeping its world transform). Returns this. */
  remove(child: Node2D): this {
    if (child._parent === this) child.setParent(null)
    return this
  }

  /**
   * Reparent this node. `keepWorld` (default true) recomputes the local transform so the on-screen
   * position/rotation/scale is unchanged; pass false to keep the local transform and move with the
   * new parent. Self/descendant parents are rejected natively (no cycles).
   */
  setParent(parent: Node2D | null, keepWorld = true): this {
    if (parent === this) return this
    if (this._parent) {
      const arr = this._parent._children
      const i = arr.indexOf(this)
      if (i >= 0) arr.splice(i, 1)
    }
    this._parent = parent
    if (parent) parent._children.push(this)
    _creator2d.setParent(this.id, parent?.id ?? 0, keepWorld)
    if (keepWorld) this._syncLocalFromNative()   // native recomputed our local transform
    return this
  }

  /** World-space position (composes all ancestors). */
  get worldPosition(): Vec2 {
    const o = _creator2d.getWorldPosition(this.id)
    return new Vec2(o[0], o[1])
  }

  /** Convert a world-space point into this node's local space (and back). */
  worldToLocal(p: Vec2Like): Vec2 {
    const o = _creator2d.worldToLocal(this.id, cx(p), cy(p))
    return new Vec2(o[0], o[1])
  }
  localToWorld(p: Vec2Like): Vec2 {
    const o = _creator2d.localToWorld(this.id, cx(p), cy(p))
    return new Vec2(o[0], o[1])
  }

  private _syncLocalFromNative(): void {
    const t = _creator2d.getLocalTransform(this.id)   // [x, y, rotDeg, sx, sy]
    this._x = t[0]; this._y = t[1]; this._rotation = t[2]; this._sx = t[3]; this._sy = t[4]
  }

  /** @internal — host animation events route here by id; the SpriteAnimation aspect installs an
   *  instance override that translates clip ids to names and emits 'loopReached' / 'completed'. */
  _dispatchAnimEvent(_clipId: number, _type: number): void {}

  /** @internal — emit a translated animation event (the SpriteAnimation aspect can't reach the
   *  protected dispatch directly). */
  _emitAnim(channel: "loopReached" | "completed", clip: string): void { this.dispatch(channel, clip) }

  /** @internal — physics contact/sensor events route here (loop.ts) → the node's 'enter'/'exit'. */
  _emitCollision(channel: "enter" | "exit", other: Node2D): void { this.dispatch(channel, other) }

  // Lazily register the host pointer callback when the first click/touchstart listener is added.
  override addEventListener<K extends keyof Node2DEvents>(channel: K, callback: Node2DEvents[K]): void {
    if (channel === "click" || channel === "touchstart") ensurePointerEvents()
    super.addEventListener(channel, callback)
  }

  /** @internal — the pointer system (g2/touch.ts) fires click/touchstart here. */
  _dispatchEvent<K extends keyof Node2DEvents>(channel: K, ...args: Parameters<Node2DEvents[K]>): void {
    this.dispatch(channel, ...args)
  }

  destroy(): void {
    // Detach from parent; native reparents our children to root (keepWorld), so resync their caches.
    if (this._parent) {
      const arr = this._parent._children
      const i = arr.indexOf(this)
      if (i >= 0) arr.splice(i, 1)
      this._parent = null
    }
    const orphans = this._children.slice()
    this._children.length = 0
    node2dRegistry.delete(this.id)
    _creator2d.destroyEntity(this.id)
    for (const c of orphans) { c._parent = null; c._syncLocalFromNative() }
  }
}
