// Base 3D scene node — an empty transform. Mesh and Light extend it, so every node operation works
// on them. Native (creator-gl) owns the authoritative transform; reads decompose a cached local
// matrix (re-synced lazily, since physics can write the transform from native each frame).
//
// Capabilities are aspects: node.aspect(Physics, …) (→ node.physics), node.aspect(Shape, …).
// A GLB import is the Model node kind (with the ModelAnimation aspect). Hierarchy lives here.

import { AspectHost } from "../core/Aspect"
import { Registry } from "../core/registry"
import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
import { Quat, cw, type QuatLike } from "../math/quat"
import { Mat4, type Mat4Like } from "../math/mat4"
import type { Geometry } from "./Geometry"
import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"
import { ensurePhysicsEvents } from "./physicsEvents"
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"

/** id → Node, so host callbacks (touch hits, animation events) route back to the owning object. */
export const nodeRegistry = new Registry<Node>()

export type NodeEvents = {
  click: (ev: ClickEvent<Node | null>) => void
  touchstart: (ev: TouchStartEvent<Node | null>) => void
  /** A looping GLB animation clip wrapped around. */
  loopReached: (clip: number) => void
  /** A non-looping GLB animation clip finished. */
  completed: (clip: number) => void
  /** AR anchors only (`scene.root` / `scene.createAnchor()`): the anchor began tracking. */
  track: () => void
  /** AR anchors only: the anchor lost tracking. */
  untrack: () => void
  /** A physics contact/trigger overlap began (the other body's node). Needs a Shape + Physics/Trigger. */
  enter: (other: Node) => void
  /** A physics contact/trigger overlap ended. */
  exit: (other: Node) => void
}

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

  /** AR anchors only — whether the anchor is currently being tracked. Kept in step with the
   *  'track' / 'untrack' events; `false` for any non-anchor node. */
  isTracked = false

  private _matrix?: Float32Array
  private _lastSync = 0
  private _worldMatrix?: Float32Array
  private _scaleCache?: [number, number, number]

  /** @internal — the active animation aspect's clip-complete handler (installed by ModelAnimation). */
  _animComplete?: (clipIndex: number) => boolean

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

  /** Geometry, if this node is a Mesh (used by physics auto-shape). Overridden by Mesh. */
  get geometry(): Geometry | undefined { return undefined }

  // --- name / visibility ---
  get name(): string { return _creator.getName(this.id) }
  set name(value: string) { _creator.setName(this.id, value) }
  get visible(): boolean { return _creator.isVisible(this.id) }
  set visible(value: boolean) { _creator.setVisible(this.id, value) }

  // --- local transform ---
  // Native owns the authoritative matrix; we keep a Float32Array cache (re-synced lazily because physics
  // can rewrite it each frame). The PUBLIC `matrix` getter hands back a fresh Mat4 *copy* (value
  // semantics — mutating it never touches the node until you assign it back). `_sync()` is the internal
  // hot path that returns the cache directly for cheap component reads.
  private _sync(): Float32Array {
    if (!this._matrix) this._matrix = new Float32Array(16)
    if (this._lastSync === 0 || Date.now() - this._lastSync > 10) {
      _creator.getMatrix(this.id, this._matrix)
      this._scaleCache = undefined
      this._lastSync = Date.now()
    }
    return this._matrix
  }

  get matrix(): Mat4 { return new Mat4(this._sync()) }
  set matrix(m: Mat4Like) {
    if (!this._matrix) this._matrix = new Float32Array(16)
    this._matrix.set(matArr(m))
    this._lastSync = Date.now()
    _creator.setMatrix(this.id, this._matrix)
  }

  get worldMatrix(): Mat4 {
    if (this.parent === null) return this.matrix
    if (!this._worldMatrix) this._worldMatrix = new Float32Array(16)
    _creator.getWorldMatrix(this.id, this._worldMatrix)
    return new Mat4(this._worldMatrix)
  }
  set worldMatrix(m: Mat4Like) {
    if (this.parent === null) { this.matrix = m; return }
    this._lastSync = 0
    _creator.setWorldMatrix(this.id, Float32Array.from(matArr(m)))
  }

  get position(): Vec3 { const m = this._sync(); return new Vec3(m[12], m[13], m[14]) }
  set position(v: Vec3Like) { this._lastSync = 0; _creator.setPosition(this.id, cx(v), cy(v), cz(v)) }

  // Scalar position accessors — the loud, correct way to nudge one axis (`node.x = 3`), so nobody reaches
  // for `node.position.x = 3` (a no-op: the getter returns a fresh copy).
  get x(): number { return this._sync()[12] }
  set x(v: number) { const m = this._sync(); this._lastSync = 0; _creator.setPosition(this.id, v, m[13], m[14]) }
  get y(): number { return this._sync()[13] }
  set y(v: number) { const m = this._sync(); this._lastSync = 0; _creator.setPosition(this.id, m[12], v, m[14]) }
  get z(): number { return this._sync()[14] }
  set z(v: number) { const m = this._sync(); this._lastSync = 0; _creator.setPosition(this.id, m[12], m[13], v) }

  get scale(): Vec3 {
    if (this._lastSync === 0 || !this._scaleCache) {
      const m = this._sync()
      this._scaleCache = [ Math.hypot(m[0], m[1], m[2]), Math.hypot(m[4], m[5], m[6]), Math.hypot(m[8], m[9], m[10]) ]
    }
    return new Vec3(this._scaleCache)
  }
  set scale(v: Vec3Like | number) {
    this._lastSync = 0
    if (typeof v === "number") _creator.setScale(this.id, v, v, v)
    else _creator.setScale(this.id, cx(v), cy(v), cz(v))
  }

  get quaternion(): Quat { return new Mat4(this._sync()).rotation }
  set quaternion(v: QuatLike) { this._lastSync = 0; _creator.setQuaternion(this.id, cx(v), cy(v), cz(v), cw(v)) }

  get eulerAngles(): Vec3 { return new Mat4(this._sync()).eulerAngles }
  // order 1 = YXZ — the SDK's euler convention (math/quat.ts); the getter also extracts YXZ, so
  // the pair round-trips. (Historically this passed 0/XYZ AND the engine stored the euler matrix
  // transposed — the setter applied the INVERSE rotation. Both fixed 2026-07-10.)
  set eulerAngles(v: Vec3Like) { this._lastSync = 0; _creator.setEulerAngles(this.id, cx(v), cy(v), cz(v), 1) }

  // --- world-space reads ---
  get forward(): Vec3 {
    const v = new Float32Array(3)
    _creator.getWorldDirection(this.id, 2, v)
    return new Vec3(-v[0], -v[1], -v[2])
  }
  get worldPosition(): Vec3 {
    const v = new Float32Array(3)
    _creator.getWorldPosition(this.id, v)
    return new Vec3(v[0], v[1], v[2])
  }
  get worldScale(): Vec3 { return this.worldMatrix.scaling }
  get worldQuaternion(): Quat { return this.worldMatrix.rotation }
  get worldEulerAngles(): Vec3 { return this.worldMatrix.eulerAngles }

  // --- hierarchy ---
  get parent(): Node | null {
    const id = _creator.getParent(this.id)
    return id === 0 ? null : (nodeRegistry.get(id) ?? new Node(id))
  }
  get children(): Node[] {
    return _creator.getChildren(this.id).map((id) => nodeRegistry.get(id) ?? new Node(id))
  }
  get childCount(): number { return _creator.getChildCount(this.id) }
  getChild(index: number): Node | null {
    const id = _creator.getChild(this.id, index)
    return id === 0 ? null : (nodeRegistry.get(id) ?? new Node(id))
  }
  /** Parent the given nodes under this one. */
  add(...children: Node[]): this {
    for (const c of children) _creator.setParent(c.id, this.id, false)
    return this
  }
  setParent(parent: Node | null, worldPositionStays = false): this {
    if (parent === null) _creator.setParentNull(this.id, worldPositionStays)
    else _creator.setParent(this.id, parent.id, worldPositionStays)
    if (worldPositionStays) this._lastSync = 0
    return this
  }
  traverse(callback: (node: Node) => void): void {
    callback(this)
    _creator.traverse(this.id, (id) => callback(nodeRegistry.get(id) ?? new Node(id)))
  }

  // --- behaviors are aspects: node.aspect(Shape, …) / node.aspect(Physics, …) ---

  /** Orient the node so the given axis (default "-z", "forward") points at a world point. */
  lookAt(point: Vec3Like, mode: "z" | "-z" | "x" | "-x" | "y" | "-y" = "-z", ortho: Vec3Like = [ 0, 1, 0 ]): this {
    const world = this.worldMatrix
    const pos = world.position
    let dir = new Vec3(point).sub(pos)
    const len = dir.length()
    if (len === 0) return this
    let axis: string = mode
    if (mode.startsWith("-")) { dir = dir.scale(-1 / len); axis = mode.slice(1) }
    else dir = dir.scale(1 / len)

    const o = new Vec3(ortho)
    let forward: Vec3, right: Vec3, up: Vec3
    if (axis === "x") {
      right = dir; up = o.cross(right).normalize(); forward = up.cross(right).normalize()
    } else if (axis === "y") {
      up = dir; right = o.cross(up).normalize(); forward = up.cross(right).normalize()
    } else {
      forward = dir; right = o.cross(dir).normalize(); up = dir.cross(right)
    }
    const s = world.scaling, w = world.m
    this.worldMatrix = Float32Array.of(
      right.x * s.x, right.y * s.x, right.z * s.x, 0,
      up.x * s.y, up.y * s.y, up.z * s.y, 0,
      forward.x * s.z, forward.y * s.z, forward.z * s.z, 0,
      w[12], w[13], w[14], w[15],
    )
    return this
  }

  // --- events ---
  override addEventListener<K extends keyof NodeEvents>(channel: K, callback: NodeEvents[K]): void {
    if (channel === "click") registerTouchEndEvent()
    else if (channel === "touchstart") registerTouchStartEvent()
    else if (channel === "enter" || channel === "exit") ensurePhysicsEvents()
    super.addEventListener(channel, callback)
  }

  /** @internal — called by the touch system to deliver a hit. */
  _dispatchEvent<K extends keyof NodeEvents>(channel: K, ...args: Parameters<NodeEvents[K]>): void {
    this.dispatch(channel, ...args)
  }

  /** @internal — emit a physics contact/trigger event (the physics-event router can't reach dispatch). */
  _emitCollision(channel: "enter" | "exit", other: Node): void { this.dispatch(channel, other) }

  /** @internal — host animation-complete callback; forwards to the ModelAnimation aspect (if any).
   *  Returns whether the clip should keep playing. */
  _onAnimationComplete(clipIndex: number): boolean {
    return this._animComplete?.(clipIndex) ?? true
  }

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

  destroy(): void {
    nodeRegistry.delete(this.id)
    _creator.destroyEntity(this.id)
  }
}

/** Read the raw column-major elements out of a Mat4 wrapper or a plain length-16 array. */
const matArr = (m: Mat4Like): ArrayLike<number> => (m as { m?: ArrayLike<number> }).m ?? (m as ArrayLike<number>)
