// A 2D scene: a draw set + an orthographic Camera2D. Opening a scene asks the host to bring up the
// 2D engine and starts its RAF loop; only the active scene renders. Grown from the Phase 0 seed.

import { Color, type ColorInput } from "../core/color"
import { _installAspectFrames, _installTimeScale, _attachSystem, _detachSystem, _detachAllSystems, type Aspect, type AspectCtor, type FieldOf, type TargetOf } from "../core/Aspect"
import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
import { cx, cy, type Vec2Like } from "../math/vec"
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
import { Camera2D } from "./Camera2D"
import { node2dRegistry, type Node2D } from "./Node2D"
import { ensurePointerEvents } from "./touch"

// Route aspect update(dt) through the render-synced native phases (c2dRender early = before physics,
// late = after animations / before draw), so followers/cameras are in lockstep with the draw. Done from
// the 2D layer (not core) so a 3D-only bundle never pulls in the 2D bridge. Installed once, lazily on
// the first Scene2D. On an older wasm without the hooks the installer returns false → setLoop fallback.
let framesInstalled = false
const installRenderSyncedFrames = (): void => {
  if (framesInstalled) return
  framesInstalled = true
  _installAspectFrames((early, late) => {
    if (typeof _creator2d.setEarlyUpdate !== "function") return false
    _creator2d.setEarlyUpdate(early)
    _creator2d.setLateUpdate(late)
    return true
  })
  // Time.scale / Time.paused reach the engine's physics + animation clocks (feature-detected).
  _installTimeScale((scale) => { _creator2d.setTimeScale?.(scale) })
}

export type Scene2DOptions = {
  /** Background color — '#10131a', 0x10131a, or [r,g,b]/[r,g,b,a] in 0..1. */
  background?: ColorInput
  /** Texture sampling for the 2D renderer: `'nearest'` (default — crisp pixel art) or `'linear'`
   *  (smooth — for hi-res / non-pixel-art assets). Applied when the scene is opened, so switching
   *  scenes restores the active scene's choice. NOTE: this is a GLOBAL renderer setting (one sampler
   *  for ALL textures, not per-texture), so the currently-open scene's filter wins. Omit to leave the
   *  current setting (engine default is nearest). */
  filter?: 'nearest' | 'linear'
}

/** Returned by scene2d.layer(n); chain .ySort() to enable top-down depth sorting for that layer. */
class LayerHandle {
  private readonly sceneId: number
  private readonly layer: number
  constructor(sceneId: number, layer: number) { this.sceneId = sceneId; this.layer = layer }
  /** In this layer, lower-on-screen sprites overlap higher ones (top-down feet sorting). */
  ySort(enabled = true): this {
    _creator2d.setLayerYSort(this.sceneId, this.layer, enabled)
    return this
  }
}

export class Scene2D implements Presentable {
  /** Native scene handle. */
  readonly id: number
  readonly camera: Camera2D

  private static _active: Scene2D | null = null
  static get active(): Scene2D | null { return Scene2D._active }

  /** @internal — scene-level pointer listeners (the catch-all; target = hit node or null). */
  readonly _clickListeners: Array<(ev: ClickEvent<Node2D | null>) => void> = []
  readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node2D | null>) => void> = []

  /** Texture filter to (re)assert on open(); the sampler is global, so each scene reclaims it. */
  private readonly _filter?: 'nearest' | 'linear'

  constructor(options: Scene2DOptions = {}) {
    installRenderSyncedFrames()
    this.id = _creator2d.createScene()
    this.camera = new Camera2D(this.id)
    this._filter = options.filter
    if (options.background !== undefined) this.background = options.background
  }

  set background(color: ColorInput) {
    const [ r, g, b, a ] = Color.toRgba01(color)
    _creator2d.sceneSetClearColor(this.id, r, g, b, a)
  }

  /** Address a draw layer to configure it, e.g. scene2d.layer(1).ySort(). */
  layer(n: number): LayerHandle {
    return new LayerHandle(this.id, n)
  }

  /** Topmost visible sprite whose world bounds contain the world point, or null. Body-free
   *  hit-test for tap/click picking — pair with camera.screenToWorld(). */
  pick(worldPoint: Vec2Like): Node2D | null {
    const id = _creator2d.pickSprite(this.id, cx(worldPoint), cy(worldPoint))
    return id ? node2dRegistry.get(id) ?? null : null
  }

  /** Scene-level pointer events. The handler fires for every tap/press in the scene, with
   *  `ev.target` = the hit Physics2D node (or null). On 'touchstart' you can ev.track(...) a drag. */
  addEventListener(channel: "click", callback: (ev: ClickEvent<Node2D | null>) => void): void
  addEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<Node2D | null>) => void): void
  addEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
    const list = channel === "click" ? this._clickListeners : this._touchStartListeners
    if (!list.includes(callback)) { list.push(callback); ensurePointerEvents() }
  }
  removeEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
    const list = channel === "click" ? this._clickListeners : this._touchStartListeners
    const i = list.indexOf(callback)
    if (i >= 0) list.splice(i, 1)
  }

  add(...nodes: Node2D[]): this {
    for (const n of nodes) _creator2d.addEntityToScene(this.id, n.id)
    return this
  }
  remove(...nodes: Node2D[]): this {
    for (const n of nodes) _creator2d.removeEntityFromScene(this.id, n.id)
    return this
  }

  // ---- systems: aspects of the scene (core/Aspect.ts `System<'x', Scene2D>`) ----
  /** @internal class → instance, the authoritative store (named accessors mirror this). */
  readonly _systems = new Map<Function, Aspect<any, any, any>>()

  /** Attach (and configure) a system, or reconfigure it if already present. Returns the scene typed
   *  as now-having it (`scene.system(Hud).hud`). A 3D `System<'x'>` (target Scene) is rejected here. */
  // `this: Self` (not `Self & Scene2D`): with the intersection TS infers Self = Scene2D and a chain
  // `scene.system(A).system(B)` loses A's accessor from the result type.
  system<Self extends TargetOf<A>, A extends Aspect<any, any, any>>(
    this: Self,
    ctor: AspectCtor<A>,
    opts?: Partial<A>,
  ): Self & FieldOf<A> {
    return _attachSystem(this as unknown as Scene2D, ctor, opts) as unknown as Self & FieldOf<A>
  }
  /** Safe access — undefined if the system isn't attached. */
  get<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): A | undefined {
    return this._systems.get(ctor) as A | undefined
  }
  /** Existence check AND type guard: inside `if (scene.has(Hud))`, `scene.hud` is present. */
  has<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this is this & FieldOf<A> {
    return this._systems.has(ctor)
  }
  /** Detach a system (runs its onDetach). Chainable. */
  removeSystem<A extends Aspect<any, any, any>>(ctor: AspectCtor<A>): this {
    _detachSystem(this, ctor)
    return this
  }

  // ---- Presentable (docs/navigation-presentable-plan.md) ----

  /** @internal presentation lifecycle — fired by the host on present/dismiss (new navigation),
   *  or synchronously by open()/close() on legacy hosts. */
  readonly ol: (() => void)[] = []
  readonly cl: (() => void)[] = []
  _backButtonCallback?: () => void
  private _vd?: object

  /** Fires when the scene becomes the visible destination (incl. a pop revealing it). */
  onOpen(callback: () => void): this {
    this.ol.push(callback)
    return this
  }
  /** Fires when the scene stops being visible (closed, replaced, or covered by a push). */
  onClose(callback: () => void): this {
    this.cl.push(callback)
    return this
  }
  onBackPressed(callback: () => void): this {
    this._backButtonCallback = callback
    return this
  }

  /** @internal stable wire descriptor; hosts hand it back via router onChange (`_p` = this). */
  _viewDesc(): object {
    return this._vd ??= { type: "scene2d", sceneId: this.id, _p: this }
  }

  /** Make this the active scene — shows it as the current destination (replaces a screen /
   *  another scene; only the active scene renders). */
  open(options?: PresentOptions): this {
    _bumpNavEpoch()
    // Reassert this scene's texture filter (the sampler is global — see Scene2DOptions.filter).
    if (this._filter !== undefined) _creator2d.setDefaultFilter(this._filter === 'linear')
    if (_navSupported()) {
      // The host activates the engine (openScene) as part of presenting the destination.
      _creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
    } else {
      _creator2d.openScene(this.id)
      for (const cb of this.ol) cb()
    }
    Scene2D._active = this
    _setCurrent(this)
    return this
  }
  close(): this {
    _bumpNavEpoch()
    if (_navSupported()) {
      _creatorUI.closeView!()
    } else {
      _creator2d.closeScene()
      for (const cb of this.cl) cb()
    }
    if (Scene2D._active === this) Scene2D._active = null
    if (Presentable.current === this) _setCurrent(null)
    return this
  }
  destroy(): void {
    _detachAllSystems(this)   // systems go first (last-attached first), while the scene still exists
    this.close()
    _creator2d.destroyScene(this.id)
  }
}
