// 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 type { CompAxis, CompWriter } from "../core/compWrite"
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"
import { ensurePhysicsEvents } from "./loop"

// 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 (the other node, plus where and how hard). The
   *  contact's `normal` points AWAY from the other node, so `normal.y > 0.7` reads as "I landed on
   *  top of it". Sensor overlaps and `exit` carry zeros. */
  enter: (other: Node2D, contact: Contact2D) => void
  exit: (other: Node2D, contact: Contact2D) => 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
}

/** Where a contact happened, delivered as the second argument of 'enter'. `speed` is the approach
 *  speed at impact (world units/s) — scale an impact sound with it. Zeros on sensor and 'exit'
 *  events, which have no manifold. */
export type Contact2D = { point: Vec2, normal: Vec2, speed: number }

/** 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> implements CompWriter {
  /** 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

  /** @internal The engine object that mirrors this node's transform, if any — a physics body or a
   *  character (0 = none). WRITES to position / x / y / rotation are routed to it as well, because
   *  the engine is the authority: on a moving body a plain setPosition is overwritten by the next
   *  sync, and on a static / sensor body it would leave the COLLIDER behind while the node moved.
   *  Set by Physics2D, Trigger2D and CharacterController2D. This is what replaced `physics.moveTo`
   *  and `trigger.moveTo`. */
  _xf = 0
  /** @internal What `_xf` is: 1 = character, 2 = physics body. */
  _xfKind = 0

  // Push this node's (already written) transform into the engine object that owns it. The world math
  // lives NATIVE — physicsSetFromEntity reads the entity's world matrix — so a parented body converts
  // through its parent chain without this file re-deriving it.
  //
  // NOTE: this fires on a DIRECT write only. A body parented to a node that moves does not follow;
  // keep bodies and sensors at the root and write their position (see docs/2d-physics-plan.md §6).
  private _pushXf(): void {
    if (this._xfKind === 2) _creator2d.physicsSetFromEntity(this._xf)
    else if (this._xfKind === 1) _creator2d.characterSetFromEntity(this._xf)
  }

  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); if (this._xf) this._pushXf() }
  get y(): number { this._pull(); return this._y }
  set y(v: number) { this._pull(); this._y = v; _creator2d.setPosition(this.id, this._x, v); if (this._xf) this._pushXf() }

  /** @internal chisel `comp_write` (see core/compWrite.ts): `node.position.<axis> = v` compiles to a call
   *  here; the scalar setters carry the physics routing. The list is compile-time data. */
  static _comps = [ "position" ]
  _writeComp(_prop: string, axis: CompAxis, v: number): void {
    if (axis === "x") this.x = v
    else if (axis === "y") this.y = 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)
    if (this._xf) this._pushXf()
  }

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

  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, contact: Contact2D): void {
    this.dispatch(channel, other, contact)
  }

  /** @internal — this node wants contact events. Set by the first 'enter'/'exit' listener; read by
   *  Physics2D.onAttach for the case where the listener was added BEFORE the body existed. */
  _wantsContacts = false

  // Lazily wire the host callbacks the listener needs. Contact/sensor events used to be registered
  // only by Trigger2D.onAttach, so solid-vs-solid 'enter' never fired in a project with no triggers —
  // and, separately, every solid shape had contact events forced on. Both are fixed here: the channel
  // is registered by the listener, and the body opts in.
  override addEventListener<K extends keyof Node2DEvents>(channel: K, callback: Node2DEvents[K]): void {
    if (channel === "click" || channel === "touchstart") ensurePointerEvents()
    else if (channel === "enter" || channel === "exit") {
      ensurePhysicsEvents()
      this._wantsContacts = true
      if (this._xfKind === 2) _creator2d.physicsSetContactEvents(this._xf, true)
    }
    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 {
    // Aspects go first: onDetach runs (a body-owning aspect releases its handle), updaters are
    // unregistered so a destroyed node never ticks again, listeners are dropped. Children survive
    // (native reparents them to the root), so only this node's aspects detach.
    this._detachAll()
    // Native frees this entity's physics bodies inside destroyEntity (they used to leak — an invisible
    // collider stayed in the world, and recycled entity ids then delivered its contacts to whoever
    // inherited the id). Clear the routing so a stale aspect can't push a dead handle.
    this._xf = 0
    this._xfKind = 0
    this._physicsDriven = false
    // 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() }
  }
}
