// 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, _installTimeScale, _attachSystem, _detachSystem, _detachAllSystems, type Aspect, type AspectCtor, type FieldOf, type TargetOf } from "../core/Aspect"
import { _bumpNavEpoch, _navEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "../ui/presentable"
import { device } from "../runtime/device"
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 AmbientOcclusionOptions = {
  /** Strength of the darkening (default 1). */
  intensity?: number
  /** How far the occlusion reaches, in metres (default 0.3). */
  radius?: number
  /** Falloff contrast; >1 tightens it into the crease (default 1). */
  power?: number
  /** Sample count + filtering (default 'medium'). Not the buffer resolution — that stays half. */
  quality?: "low" | "medium" | "high" | "ultra"
}

export type FogOptions = {
  /** A TINT on the in-scattered ambient, not an absolute colour: the engine multiplies it by the
   *  environment luminance, so white (the default) means "fog as bright as the ambient" and the fog
   *  brightens with `environmentIntensity`. Do NOT expect the same hex to look like it does in
   *  `skybox` — that one IS an absolute radiance and reads roughly an order of magnitude brighter.
   *  Tint towards the sky's hue; leave it white to sit at the ambient level. */
  color?: ColorInput
  /** Metres from the camera before the fog starts (default 0). */
  start?: number
  /** Extinction per metre at `height`; 0.01 ≈ clearly visible over ~100 m (default 0.01). */
  density?: number
  /** The fog's "sea level" in world Y (default 0). */
  height?: number
  /** How fast it thins with altitude, 1/m. 0 = uniform everywhere; higher = a ground-hugging layer
   *  you can see over (default 0). */
  heightFalloff?: number
  /** Cap on how opaque it can get, 0–1 — keeps far shapes readable (default 1). */
  maxOpacity?: number
  /** Metres after which fog stops applying; 0 = everywhere (default 0). */
  cutoff?: number
  /** Take the colour from the environment in the view direction, tinted by `color`, instead of a
   *  flat `color`. Convincing when the IBL is a real sky (default false). */
  fromEnvironment?: boolean
}

export type SceneOptions = {
  /** Image-based ambient lighting (default on). */
  ibl?: boolean
  /** IBL intensity (default 20000). */
  environmentIntensity?: number
  /** Bloom post-processing. */
  bloom?: boolean
  bloomIntensity?: number
  /** Tone mapping operator. `'aces'` (default, filament's ACES legacy) desaturates bright colours
   *  towards white — HDR fire reads pale; `'neutral'` (Khronos PBR Neutral) keeps hue and
   *  saturation until very bright; `'linear'` clips each channel (what an engine without a
   *  tonemapper shows — saturated, Unity-without-post-processing look); `'filmic'` (Uncharted). */
  toneMapping?: "aces" | "neutral" | "linear" | "filmic"
  /** The sky. Three forms:
   *  - a colour — a flat clear colour;
   *  - `{ texture }` — a KTX1 **cubemap**, the sharp `<name>_skybox.ktx` that filament's `cmgen`
   *    produces from an equirectangular .hdr/.exr. This is the one to use for a real sky. Pass an
   *    `asset('../assets/sky.ktx')` handle or a bare staged filename;
   *  - `'environment'` — reuse the scene's IBL cubemap. Cheapest (no second texture) but that file
   *    is prefiltered for roughness, so the sky comes out soft; fine as a fallback, not as the goal.
   *
   *  Either texture form draws through filament's own skybox pass: a full-screen pass in device
   *  space after the opaque queue. No geometry, no meridian seam, no pole distortion — do NOT build
   *  a sky dome or a fullscreen equirect material by hand, both are strictly worse. */
  skybox?: ColorInput | "environment" | { texture: string }
  /** Screen-space ambient occlusion — the contact darkening in creases and where props meet the
   *  ground. Without it an IBL lights a crease exactly as brightly as an open face, so everything
   *  reads as pasted onto the floor rather than standing on it. `true` takes defaults tuned for
   *  human-scale props; `radius` is world-space metres and is the one knob that must follow the
   *  scene's scale (~0.3 for objects on a table, ~0.6-1 for a yard of crates and containers). */
  ambientOcclusion?: boolean | AmbientOcclusionOptions
  /** Distance fog / aerial perspective: distant geometry loses contrast so the eye reads depth, and
   *  the hard edge where a finite level ends against the skybox goes away. By default the fog applies
   *  at every distance — the skybox included — so the sky itself takes the fog colour and the horizon
   *  blends on its own; `cutoff` opts geometry beyond a distance back out. */
  fog?: FogOptions
  /** Multisample anti-aliasing: `true` = 4×, `2`/`4` = that many samples, `false` = off (FXAA
   *  takes over). Unset keeps the host default (desktop 4×, mobile/web off). The biggest single
   *  fill-rate cost after resolution — turn it down on big screens before anything else. */
  antialias?: boolean | 2 | 4
  /** Render the 3D at this fraction of the viewport (0.25–1) and upscale; the UI stays at native
   *  resolution. A fixed, predictable cut of per-pixel GPU work — `0.75` is ~45 % cheaper and
   *  barely visible in motion, `0.5` quarters it. Headless renders ignore it. */
  renderScale?: number
  /** Let the engine shrink the 3D buffers under `renderScale` only when frames run over budget
   *  (Filament dynamic resolution, sharpened upscale) down to `min` (default 0.5). Off by default:
   *  it makes the output frame-time dependent, so flow tests / headless renders never enable it. */
  dynamicResolution?: boolean | { min?: number }
  /** Anisotropic filtering, 1 (off) … 16. Default 2. What it buys is detail on surfaces seen at a
   *  GRAZING angle — a first-person weapon, a floor, a wall — where the sample footprint is far
   *  longer in one axis than the other and isotropic filtering has to pick a mip for the long one,
   *  several levels coarser than the short axis deserves. Measured on a weapon seen from the side
   *  (local contrast): 8.18 at 1× → 10.02 at 2× → 10.84 at 4× → 11.29 at 8×. The default is 2
   *  because it is where the curve is steepest per unit of memory bandwidth; raise it if you have
   *  the headroom.
   *
   *  It is an ENGINE-WIDE default, not a property of this scene: a sampler is baked when its
   *  texture is bound, so this reaches the models a scene loads AFTER it (which is every model in a
   *  scene file — `env` is applied before the nodes build) and leaves already-loaded ones alone.
   *  On the desktop host `CREATOR_TEXTURE_ANISOTROPY` overrides it, for tuning without a rebuild. */
  anisotropy?: number
  /** The look on an HDR display (a screen with headroom above SDR white — Apple XDR panels, the
   *  macOS host today); ignored on SDR. `strength` 0..1 is how much of the picture reaches for the
   *  display's headroom (0 only what SDR clipped, 1 nearly everything; default 0.35). `paperWhite`
   *  1..8 is where white lands as a multiple of SDR white — the "HDR brightness" of a console
   *  calibration screen, where 1.5–2 is the norm; default 1 keeps a white wall at the UI's white,
   *  faithful but dim next to what "HDR on" is expected to look like. Engine-wide like `anisotropy`;
   *  `device.hdr` is the runtime form and tells you whether the display has any headroom at all. */
  hdr?: { strength?: number; paperWhite?: number }
}

// 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
    // the frame stamp invalidates every Node's local-matrix cache once per frame (physics / character
    // controllers / animation rewrite transforms natively between frames)
    _creator.setEarlyUpdate((dt: number) => { glState.frame++; early(dt) })
    _creator.setLateUpdate(late)
    return true
  }, (fixed) => {
    // The fixed phase runs INSIDE the engine's substep loop (physics/world.cpp) when the host has the
    // hook; each substep moves bodies natively, so the frame stamp bumps per step too. Feature-detected:
    // an older host returns false and the dispatcher steps its own 1/60 accumulator.
    if (typeof _creator.setFixedUpdate !== "function") return false
    _creator.setFixedUpdate((dt: number) => { glState.frame++; fixed(dt) })
    return true
  })
  // Time.scale / Time.paused reach the engine's own clocks (physics, animators, particles) through
  // setTimeScale; a host without it keeps running its sims at wall-clock speed (feature-detected).
  _installTimeScale((scale) => { _creator.setTimeScale?.(scale) })
}

export class Scene implements Presentable {
  /** @internal native scene handle. */
  readonly _id: number
  /** @internal the IBL intensity this scene was built with (Lightmap.load's ambient floor). */
  _environmentIntensity = 20000
  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)
    this._environmentIntensity = options.ibl === false ? 0 : (options.environmentIntensity ?? 20000)
    if (options.bloom) _creator.setBloomOptions(this._id, true, options.bloomIntensity ?? 0.2, 1)
    if (options.toneMapping !== undefined && options.toneMapping !== "aces") {
      _creator.setToneMapping?.(this._id, { neutral: 1, linear: 2, filmic: 3 }[options.toneMapping] ?? 0)
    }
    if (options.skybox !== undefined) this.skybox = options.skybox
    if (options.ambientOcclusion) this.setAmbientOcclusion(options.ambientOcclusion)
    if (options.fog) this.setFog(options.fog)
    if (options.antialias !== undefined) {
      const a = options.antialias
      // `false` must reach the host: desktop defaults to 4× MSAA, so an explicit off is a real change.
      _creator.setSceneMultiSampleAntiAliasing(this._id, a !== false, typeof a === "number" ? a : 4)
    }
    // Before anything else in this constructor that could load a texture: the value has to be in
    // place by the time assets bind their samplers.
    if (options.anisotropy !== undefined) _creator.setTextureAnisotropy?.(options.anisotropy)
    if (options.hdr) {
      if (options.hdr.strength !== undefined) device.hdr.strength = options.hdr.strength
      if (options.hdr.paperWhite !== undefined) device.hdr.paperWhite = options.hdr.paperWhite
    }
    if (options.renderScale !== undefined || options.dynamicResolution) {
      this.setRenderOptions(options.renderScale ?? 1, options.dynamicResolution ?? false)
    }
  }

  /** Runtime form of `renderScale` / `dynamicResolution` (a graphics-settings menu). `renderScale`
   *  0.25–1; older hosts without the method ignore it. */
  setRenderOptions(renderScale: number, dynamicResolution: boolean | { min?: number } = false): void {
    const min = typeof dynamicResolution === "object" ? (dynamicResolution.min ?? 0.5) : 0.5
    _creator.setSceneRenderOptions?.(this._id, renderScale, !!dynamicResolution, min)
  }

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

  set skybox(sky: ColorInput | "environment" | { texture: string }) {
    if (sky === "environment") { _creator.setSkyboxFromEnvironment?.(this._id); return }
    if (typeof sky === "object" && sky !== null && "texture" in sky) {
      const src = sky.texture
      // `asset()` hands back "id:<n>" (already staged); a bare name is resolved on the spot.
      const id = src.startsWith("id:") ? Number(src.slice(3)) : _creatorUtils.fetchLocal(src)
      _creator.setSkyboxTexture?.(this._id, id)
      return
    }
    _creator.setSkybox(this._id, Color.toPackedRgb(sky))
  }

  /** Runtime form of `ambientOcclusion` (a graphics-settings menu). `false` turns it off. */
  setAmbientOcclusion(options: boolean | AmbientOcclusionOptions): void {
    const o: AmbientOcclusionOptions = typeof options === "object" ? options : {}
    const quality = { low: 0, medium: 1, high: 2, ultra: 3 }[o.quality ?? "medium"] ?? 1
    _creator.setAmbientOcclusionOptions?.(this._id, options !== false, o.intensity ?? 1, o.radius ?? 0.3, o.power ?? 1, quality)
  }

  /** Runtime form of `fog` (weather, entering a building). `false` turns it off. */
  setFog(options: FogOptions | false): void {
    const o: FogOptions = options === false ? {} : options
    _creator.setFogOptions?.(
      this._id, options !== false, Color.toPackedRgb(o.color ?? "#ffffff"),
      o.start ?? 0, o.density ?? 0.01, o.height ?? 0, o.heightFalloff ?? 0,
      o.maxOpacity ?? 1, o.cutoff ?? 0, o.fromEnvironment ?? false,
    )
  }

  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
  }

  // ---- systems: aspects of the scene (core/Aspect.ts `System`) ----
  /** @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 `System<'x', Scene2D>` is rejected here. */
  // `this: Self` (not `Self & Scene`): with the intersection TS infers Self = Scene 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 Scene, 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
  }
  /** Tear the scene down: every system detaches (last-attached first) and the scene closes. Nodes
   *  are yours — destroy the ones you own; the native scene object itself is not released. */
  destroy(): void {
    _detachAllSystems(this)
    this.close()
  }

  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()
  }
}
