// The scene camera — a Node (so position / lookAt / transform all work) plus projection reads and
// screen↔world ray helpers. Created and attached by Scene. Ported from worker/src/components/Camera.ts.

import { Vec3 } from "../math/vec"
import { Aspect } from "../core/Aspect"
import { Node } from "./Node"
import { Ray } from "./Ray"

/** @internal `camera.follow(node)`: copies the node's world pose onto the camera every LATE frame
 *  (order 1000 — after every other aspect has moved things). One per camera; lives on the target. */
export class CameraFollowRig extends Aspect<"__cameraFollow"> {
  static readonly aspect = "__cameraFollow"
  /** @internal */ _camera!: Camera
  constructor() { super(); this.updateOrder = 1000 }
  update(): void {
    const cam = this._camera
    cam.position = this.node.worldPosition
    cam.quaternion = this.node.worldQuaternion
  }
}

/** Projection every host starts a scene with (creator-gl scene.cpp / the lite core agree on these). */
export const CAMERA_DEFAULTS = { fov: 60, near: 0.01, far: 1000 }

export class Camera extends Node {
  private _sceneId = -1
  /** Live projection matrix (updated by the host when the viewport/fov changes). */
  readonly projectionMatrix = new Float32Array(16)

  // Projection the app asked for (host defaults until it does). The host owns the aspect and
  // re-applies these on every viewport change, so they are set, not maintained.
  private _fov = CAMERA_DEFAULTS.fov
  private _near = CAMERA_DEFAULTS.near
  private _far = CAMERA_DEFAULTS.far
  /** Stay quiet until the app actually sets something — hosts may be configured with their own
   *  defaults (the headless core takes a `fov` option) and a blind push would overwrite them. */
  private _projSet = false

  // Exposure the app asked for (filament's physical defaults until it does): f/16, 1/125 s, ISO 100 =
  // EV100 15, bright sunlight — what every light intensity in the docs is tuned against.
  private _exposure = { aperture: 16, shutterSpeed: 1 / 125, iso: 100 }
  private _ev = 0
  private _exposureSet = false

  /** @internal — Scene wires the camera to its native scene id + projection callback. */
  _attach(sceneId: number): void {
    this._sceneId = sceneId
    _creator.attachCameraToAR(sceneId, this.id, (mat) => {
      for (let i = 0; i < 16; i++) this.projectionMatrix[i] = mat[i]
    })
    if (this._projSet) this._applyProjection()
    if (this._exposureSet) this._applyExposure()
  }

  /** Exposure compensation in stops (default 0): +1 doubles how bright the scene renders, −2
   *  quarters it. The one exposure knob most games need — a dark interior, a flash of white, or eye
   *  adaptation when the sun swings into view (drop it, and the sun stays at the display's peak
   *  while everything else darkens). Scene light follows it; particle `emissive` is post-exposure
   *  and does not, so a fireball keeps its on-screen brightness. Cheap to animate every frame. */
  get exposureCompensation(): number { return this._ev }
  set exposureCompensation(stops: number) {
    this._ev = Number.isFinite(stops) ? stops : 0
    this._exposureSet = true
    this._applyExposure()
  }

  /** The physical camera: `aperture` in f-stops, `shutterSpeed` in seconds, `iso`, plus the same
   *  `compensation` in stops — any subset, one call. `camera.setExposure({ iso: 400 })` is two stops
   *  brighter than the default f/16 · 1/125 · ISO 100. */
  setExposure(exposure: { aperture?: number, shutterSpeed?: number, iso?: number, compensation?: number }): this {
    if (exposure.aperture !== undefined) this._exposure.aperture = exposure.aperture
    if (exposure.shutterSpeed !== undefined) this._exposure.shutterSpeed = exposure.shutterSpeed
    if (exposure.iso !== undefined) this._exposure.iso = exposure.iso
    if (exposure.compensation !== undefined) this._ev = exposure.compensation
    this._exposureSet = true
    this._applyExposure()
    return this
  }

  /** Hosts that predate the call keep the fixed default (older wasm / native builds). */
  private _applyExposure(): void {
    if (this._sceneId < 0) return
    const e = this._exposure
    _creator.setCameraExposure?.(this._sceneId, e.aperture, e.shutterSpeed, e.iso * 2 ** this._ev)
  }

  /** Vertical field of view in degrees (default 60) — a smaller angle is a longer lens. */
  get fov(): number { return _creator.getCameraFov(this._sceneId, 0) }
  set fov(degrees: number) { this._fov = degrees; this._projSet = true; this._applyProjection() }
  get horizontalFov(): number { return _creator.getCameraFov(this._sceneId, 1) }

  /** Near clip distance (default 0.01) — nothing closer than this draws. */
  get near(): number { return this._near }
  set near(distance: number) { this._near = distance; this._projSet = true; this._applyProjection() }

  /** Far clip distance / view range (default 1000) — geometry past it is culled. Shadows keep
   *  their own (shorter) range, so a long view distance doesn't cost shadow sharpness. */
  get far(): number { return this._far }
  set far(distance: number) { this._far = distance; this._projSet = true; this._applyProjection() }

  /** Set any of fov / near / far in one call: `camera.setProjection({ fov: 45, far: 5000 })`. */
  setProjection(projection: { fov?: number, near?: number, far?: number }): this {
    if (projection.fov !== undefined) this._fov = projection.fov
    if (projection.near !== undefined) this._near = projection.near
    if (projection.far !== undefined) this._far = projection.far
    this._projSet = true
    this._applyProjection()
    return this
  }

  /** Hosts that predate the call keep the fixed defaults (older wasm / native builds). */
  private _applyProjection(): void {
    if (this._sceneId < 0 || typeof _creator.setCameraProjection !== "function") return
    _creator.setCameraProjection(this._sceneId, this._fov, this._near, this._far)
  }
  private _following: Node | null = null
  /** The node the camera currently follows (`follow()`), or null. */
  get following(): Node | null { return this._following }

  /**
   * Make the camera ride a node: every late frame the camera takes the node's WORLD pose (−Z =
   * view direction), so movement aspects / animation on that node are camera moves — dolly shots,
   * a cutscene path, a `CameraPlace` in a scene file. A node carrying a `CameraPlace` also hands
   * over its projection (fov / near / far). `follow(null)` releases the camera where it is.
   * One camera per scene: following a new node stops following the previous one.
   */
  follow(target: Node | { node: Node, applyTo(camera: Camera): void } | null): this {
    const node = target === null ? null : target instanceof Node ? target : target.node
    if (this._following && this._following !== node) this._following.removeAspect(CameraFollowRig)
    this._following = node
    if (!node) return this
    // a CameraPlace on the node (or passed directly) sets the lens too
    const place = target instanceof Node
      ? (node as unknown as { cameraPlace?: { applyTo(camera: Camera): void } }).cameraPlace
      : target as { applyTo(camera: Camera): void }
    place?.applyTo(this)
    node.aspect(CameraFollowRig, { _camera: this })
    this.position = node.worldPosition
    this.quaternion = node.worldQuaternion
    return this
  }

  get displaySize(): [number, number] {
    const s = _creator.getDisplaySize()
    return [ s[0], s[1] ]
  }

  /** World-space ray direction through a screen pixel. */
  getViewDirection(screenX: number, screenY: number): Vec3 {
    if (this._sceneId < 0) throw new Error("Camera is not attached to a scene")
    const v = new Float32Array(3)
    _creator.getCameraViewDirection(this._sceneId, screenX, screenY, v)
    return new Vec3(v[0], v[1], v[2])
  }

  /** A pick ray from the camera through a screen pixel. */
  getRay(screenX: number, screenY: number): Ray {
    return new Ray(this.worldPosition, this.getViewDirection(screenX, screenY))
  }
}
