// A 3D scene: a draw set, a Camera, lights and environment. Opening a scene asks the host to bring up
// the 3D engine; only the active scene renders. Grown from the Phase 0 seed. Ported from
// worker/src/Scene.ts (Scene + ARScene), with the camera exposed as scene.camera.
//
// Lights are explicit: a scene gets image-based ambient (IBL) by default; add a Light.sun() for
// direct light + shadows.

import { Color, type ColorInput } from "../core/color"
import { _installAspectFrames } from "../core/Aspect"
import { _bumpNavEpoch, _navEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
import type { ClickEvent, TouchStartEvent } from "../runtime/touch"
import type { FetchResponse } from "../runtime/fetch"
import { _requestCameraPermission } from "../plugins/permission"
import { Camera } from "./Camera"
import { attachControls, type ControlsHandle, type ControlsOptions } from "./controls"
import { Material } from "./Material"
import { Node, nodeRegistry } from "./Node"
import { glState } from "./state"
import { registerTouchEndEvent, registerTouchStartEvent } from "./touch"

export type SceneOptions = {
  /** Image-based ambient lighting (default on). */
  ibl?: boolean
  /** IBL intensity (default 20000). */
  environmentIntensity?: number
  /** Bloom post-processing. */
  bloom?: boolean
  bloomIntensity?: number
  /** Skybox / clear color. */
  skybox?: ColorInput
  /** Multisample anti-aliasing. */
  antialias?: boolean
}

// Route aspect update(dt) through the render-synced native phases (render() early = before physics,
// late = after animations / before draw), so followers/cameras are in lockstep with the draw. Done from
// the 3D layer (not core) so a 2D-only bundle never pulls in the 3D bridge. Installed once, lazily on
// the first Scene. 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 _creator.setEarlyUpdate !== "function") return false
    _creator.setEarlyUpdate(early)
    _creator.setLateUpdate(late)
    return true
  })
}

export class Scene implements Presentable {
  /** @internal native scene handle. */
  readonly _id: number
  readonly camera: Camera

  /** @internal touch listeners, read by the dispatch system. */
  readonly _clickListeners: Array<(ev: ClickEvent<Node | null>) => void> = []
  readonly _touchStartListeners: Array<(ev: TouchStartEvent<Node | null>) => void> = []

  private _material?: Material

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

  constructor(options: SceneOptions = {}) {
    installRenderSyncedFrames()
    const parent = (options as any)._parent as Scene | undefined
    if (parent) {
      this.camera = parent.camera
      this._id = _creator.createOverlayScene(parent._id)
      // No camera re-attach: the native createOverlayScene wires the parent's camera into the
      // overlay view. Re-attaching would (a) point camera._sceneId — the source for getRay /
      // getViewDirection / fov — at the overlay's view, whose viewport/projection aren't
      // maintained like the render view's, and (b) create a second native camera component on
      // the shared entity. (Found via the scene editor: gizmo pick rays came out distorted.)
    } else {
      this.camera = new Camera()
      this._id = _creator.createScene()
      this.camera._attach(this._id)
    }

    if (options.ibl !== false) _creator.setDefaultIbl(this._id, options.environmentIntensity ?? 20000)
    if (options.bloom) _creator.setBloomOptions(this._id, true, options.bloomIntensity ?? 0.2, 1)
    if (options.skybox !== undefined) _creator.setSkybox(this._id, Color.toPackedRgb(options.skybox))
    if (options.antialias) _creator.setSceneMultiSampleAntiAliasing(this._id, true, 4)
  }

  /** Scene occlusion material. */
  get occlusionMaterial(): Material {
    if (!this._material) this._material = new Material(_creator.getSceneMaterial(this._id))
    return this._material
  }

  set skybox(color: ColorInput) { _creator.setSkybox(this._id, Color.toPackedRgb(color)) }

  setMaterialGlobalParameter(i: number, x: number, y: number, z: number, w: number): void {
    _creator.setMaterialGlobalParameter(this._id, i, x, y, z, w)
  }
  setAntialias(enabled: boolean, scale = 4): void {
    _creator.setSceneMultiSampleAntiAliasing(this._id, enabled, scale)
  }

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

  createOverlay(options: SceneOptions = {}): Scene {
    return new Scene({ ...options, _parent: this } as any)
  }

  warmRender(): Promise<void> {
    return new Promise<void>((res) => _creator.warmRender(this._id, res))
  }

  // ---- Presentable (docs/navigation-presentable-plan.md) ----
  // A scene is a destination like a screen: opening it replaces whatever is visible (suspending
  // an active Router until Router.restore()), and it can be pushed onto the Router stack. The
  // scene graph survives while covered — only the view detaches and rendering pauses.

  /** @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: "scene3d", 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): void | Promise<void> {
    _bumpNavEpoch()
    if (_navSupported()) {
      // The host activates the engine (openScene) as part of presenting the destination.
      _creatorUI.openView!(this._viewDesc(), options?.transition ?? "none")
    } else {
      _creator.openScene(this._id)
      for (const cb of this.ol) cb()
    }
    Scene._active = this
    glState.activeScene = this
    _setCurrent(this)
  }
  close(): void {
    _bumpNavEpoch()
    if (_navSupported()) {
      _creatorUI.closeView!()
    } else {
      _creator.closeScene()
      for (const cb of this.cl) cb()
    }
    if (Scene._active === this) Scene._active = null
    if (glState.activeScene === this) glState.activeScene = null
    if (Presentable.current === this) _setCurrent(null)
  }

  addEventListener(channel: "click", callback: (ev: ClickEvent<Node | null>) => void): void
  addEventListener(channel: "touchstart", callback: (ev: TouchStartEvent<Node | null>) => void): void
  addEventListener(channel: "click" | "touchstart", callback: (ev: any) => void): void {
    if (channel === "click") {
      if (!this._clickListeners.includes(callback)) { this._clickListeners.push(callback); registerTouchEndEvent() }
    } else if (channel === "touchstart") {
      if (!this._touchStartListeners.includes(callback)) { this._touchStartListeners.push(callback); registerTouchStartEvent() }
    }
  }
  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)
  }
}

// --- AR ---

export type ARMode = "default" | "markers" | "arcore" | "detached"

const onTrackStateChange = (entityId: number, track: boolean): void => {
  const node = nodeRegistry.get(entityId)
  if (!node) return
  node.isTracked = track
  if (track) node._dispatchEvent("track")
  else node._dispatchEvent("untrack")
}

export class ARScene<T extends ARMode = "default"> extends Scene {
  readonly mode: T
  useWarmRender = true
  private _root?: Node

  constructor(options: SceneOptions & { mode?: T } = {}) {
    super(options)
    this.mode = options.mode ?? ("default" as T)
    _creator.createARController(this._id, this.camera.id, this.mode, onTrackStateChange)
  }

  /** Root anchor node (not available in marker mode). */
  get root(): Node {
    if (this._root) return this._root
    this._root = new Node(_creator.createRootAnchor(this._id))
    return this._root
  }

  /** Create an anchor from a tracking source (e.g. an image target). */
  createAnchor(source: FetchResponse, physicalWidth = 0.2): Node {
    return new Node(_creator.createAnchor(this._id, physicalWidth, (source as any)._id))
  }

  /**
   * Let the user place & manipulate a node with touch gestures — the staple AR object-placement
   * interaction: ONE finger drags it across the floor (relative to where the camera looks), TWO fingers
   * pinch to scale and twist to rotate. Returns a handle; call `.remove()` to detach. Tunable via
   * options (min/max scale, and toggles for `pan` / `pinch` / `twist`).
   */
  addControls(target: Node, options: ControlsOptions = {}): ControlsHandle {
    return attachControls(this, target, options)
  }

  override async open(options?: PresentOptions): Promise<void> {
    await this._prepare()
    super.open(options)
  }

  /** @internal Presentable prepare hook: camera permission + warm render + AR session, all while
   *  the previous destination (typically a loading screen) stays visible — the swap to this scene
   *  happens only after it resolves. Warm render is headless on every platform (offscreen
   *  swapchain), so no GL view is created here — the host mounts the scene view at present time.
   *  Rejects on camera denial / AR launch failure, or if another navigation happened while
   *  preparing (superseded) — in every rejection case the current destination stays visible. */
  async _prepare(): Promise<void> {
    const epoch = _navEpoch()
    await _requestCameraPermission()
    if (this.useWarmRender) await this.warmRender()
    await new Promise<void>((res, rej) => _creator.launchAR(this._id, res, rej))
    if (_navEpoch() !== epoch) {
      _creator.stopAR()
      throw new Error("ARScene.open() superseded: another destination was opened while preparing")
    }
  }

  override close(): void {
    _creator.stopAR()
    super.close()
  }
}

// --- VR ---

/**
 * A 3D scene rendered in a VR headset (OpenXR — e.g. Meta Quest). The headset drives the camera:
 * every frame both eyes are rendered from the tracked head pose, so `scene.camera` position/lookAt
 * have no effect while the scene is open. The world origin is at floor level (stage space) —
 * author content with the ground at y = 0 and the user standing at the origin.
 *
 * `open()` rejects on hosts without a VR runtime ("VR is not supported on this device").
 */
export class VRScene extends Scene {
  override async open(options?: PresentOptions): Promise<void> {
    await new Promise<void>((res, rej) => _creator.launchVR(this._id, res, rej))
    super.open(options)
  }

  override close(): void {
    _creator.stopVR()
    super.close()
  }
}
