// Projection: view space -> clip space -> NDC.
//
// CONVENTIONS (fixed for the whole library):
//   * World & view space: RIGHT-HANDED, +X right, +Y up, camera looks down -Z.
//   * Clip space: homogeneous (x, y, z, w). Perspective gives w = -z_view.
//   * NDC: x in [-1, 1] (left->right), y in [-1, 1] (bottom->top), z by `DepthMode`.

///|
/// How NDC depth is laid out.
///  - `ZeroToOne`:   near -> 0, far -> 1   (Vulkan / D3D / Metal / WebGPU native)
///  - `NegOneToOne`: near -> -1, far -> 1  (OpenGL default)
///  - `ReversedZ`:   near -> 1, far -> 0   (best float precision; use with a GREATER depth test
///    and clear depth to 0). With an infinite far plane this is the recommended modern setup.
pub(all) enum DepthMode {
  ZeroToOne
  NegOneToOne
  ReversedZ
} derive(Eq, Debug)

///|
pub(all) enum ProjKind {
  Perspective
  Orthographic
} derive(Eq, Debug)

///|
/// A projection volume. `left/right/bottom/top` are the extents in VIEW-space units measured
/// at the NEAR plane (perspective) or of the box (orthographic). `far` may be
/// `@double.infinity` for perspective projections.
#valtype
pub(all) struct Projection {
  kind : ProjKind
  left : Double
  right : Double
  bottom : Double
  top : Double
  near : Double
  far : Double
  depth : DepthMode
} derive(Eq, Debug)

///|
/// Symmetric perspective from vertical field of view (radians) and width/height aspect.
/// `far` may be `@double.infinity`.
pub fn Projection::perspective(
  fov_y : Double,
  aspect : Double,
  near : Double,
  far : Double,
  depth : DepthMode,
) -> Projection {
  if !(fov_y > 0.0 && fov_y < PI) {
    abort("Projection::perspective: fov_y must be in (0, PI)")
  }
  if !(aspect > 0.0) {
    abort("Projection::perspective: aspect must be > 0")
  }
  let top = near * @math.tan(fov_y * 0.5)
  Projection::frustum(-top * aspect, top * aspect, -top, top, near, far, depth)
}

///|
/// Off-centre perspective frustum (glFrustum): extents on the near plane. Used for
/// asymmetric FOV (VR eyes), tiled/sub-rect rendering, lens shift, portals.
pub fn Projection::frustum(
  left : Double,
  right : Double,
  bottom : Double,
  top : Double,
  near : Double,
  far : Double,
  depth : DepthMode,
) -> Projection {
  if !(near > 0.0) {
    abort("Projection::frustum: near must be > 0")
  }
  if !(far > near) {
    abort("Projection::frustum: far must be > near")
  }
  if !(right > left && top > bottom) {
    abort("Projection::frustum: need right > left and top > bottom")
  }
  { kind: Perspective, left, right, bottom, top, near, far, depth, }
}

///|
/// Asymmetric perspective from four half-angles (radians, all measured outward from the view
/// axis and normally positive): the VR/OpenXR "field of view" form.
pub fn Projection::perspective_fov(
  angle_left : Double,
  angle_right : Double,
  angle_down : Double,
  angle_up : Double,
  near : Double,
  far : Double,
  depth : DepthMode,
) -> Projection {
  Projection::frustum(
    -near * @math.tan(angle_left),
    near * @math.tan(angle_right),
    -near * @math.tan(angle_down),
    near * @math.tan(angle_up),
    near,
    far,
    depth,
  )
}

///|
pub fn Projection::orthographic(
  left : Double,
  right : Double,
  bottom : Double,
  top : Double,
  near : Double,
  far : Double,
  depth : DepthMode,
) -> Projection {
  if !(far > near) {
    abort("Projection::orthographic: far must be > near")
  }
  if far.is_inf() {
    abort("Projection::orthographic: far must be finite")
  }
  if !(right > left && top > bottom) {
    abort("Projection::orthographic: need right > left and top > bottom")
  }
  { kind: Orthographic, left, right, bottom, top, near, far, depth, }
}

///|
/// Centered orthographic box of the given width / height.
pub fn Projection::orthographic_centered(
  width : Double,
  height : Double,
  near : Double,
  far : Double,
  depth : DepthMode,
) -> Projection {
  Projection::orthographic(
    -width * 0.5,
    width * 0.5,
    -height * 0.5,
    height * 0.5,
    near,
    far,
    depth,
  )
}

///|
pub fn Projection::is_perspective(self : Projection) -> Bool {
  self.kind == Perspective
}

///|
pub fn Projection::is_infinite_far(self : Projection) -> Bool {
  self.far.is_inf()
}

///|
/// (A, B) such that  z_clip = A * z_view + B  (perspective: w = -z_view, so
/// z_ndc = (A z + B) / -z;  orthographic: w = 1, z_ndc = A z + B).
fn Projection::depth_coeffs(self : Projection) -> (Double, Double) {
  let n = self.near
  let f = self.far
  match self.kind {
    Perspective =>
      if f.is_inf() {
        match self.depth {
          ZeroToOne => (-1.0, -n)
          NegOneToOne => (-1.0, -2.0 * n)
          ReversedZ => (0.0, n)
        }
      } else {
        let d = f - n
        match self.depth {
          ZeroToOne => (-f / d, -f * n / d)
          NegOneToOne => (-(f + n) / d, -2.0 * f * n / d)
          ReversedZ => (n / d, n * f / d)
        }
      }
    Orthographic => {
      let d = f - n
      match self.depth {
        ZeroToOne => (-1.0 / d, -n / d)
        NegOneToOne => (-2.0 / d, -(f + n) / d)
        ReversedZ => (1.0 / d, f / d)
      }
    }
  }
}

///|
/// The 4x4 projection matrix (column vectors, v_clip = P * v_view).
pub fn Projection::to_mat4(self : Projection) -> Mat4 {
  let (a, b) = self.depth_coeffs()
  let rl = self.right - self.left
  let tb = self.top - self.bottom
  match self.kind {
    Perspective => {
      let n2 = 2.0 * self.near
      {
        m00: n2 / rl,
        m01: 0.0,
        m02: (self.right + self.left) / rl,
        m03: 0.0,
        m10: 0.0,
        m11: n2 / tb,
        m12: (self.top + self.bottom) / tb,
        m13: 0.0,
        m20: 0.0,
        m21: 0.0,
        m22: a,
        m23: b,
        m30: 0.0,
        m31: 0.0,
        m32: -1.0,
        m33: 0.0,
      }
    }
    Orthographic =>
      {
        m00: 2.0 / rl,
        m01: 0.0,
        m02: 0.0,
        m03: -(self.right + self.left) / rl,
        m10: 0.0,
        m11: 2.0 / tb,
        m12: 0.0,
        m13: -(self.top + self.bottom) / tb,
        m20: 0.0,
        m21: 0.0,
        m22: a,
        m23: b,
        m30: 0.0,
        m31: 0.0,
        m32: 0.0,
        m33: 1.0,
      }
  }
}

///|
/// Closed-form inverse (clip -> view), much cheaper and more accurate than a general
/// 4x4 inverse. The result of `inverse * clip` has a w component that must be divided out
/// for perspective (use `Mat4::project_point`).
pub fn Projection::inverse_mat4(self : Projection) -> Mat4 {
  let (a, b) = self.depth_coeffs()
  let rl = self.right - self.left
  let tb = self.top - self.bottom
  match self.kind {
    Perspective => {
      let m00 = 2.0 * self.near / rl
      let m02 = (self.right + self.left) / rl
      let m11 = 2.0 * self.near / tb
      let m12 = (self.top + self.bottom) / tb
      {
        m00: 1.0 / m00,
        m01: 0.0,
        m02: 0.0,
        m03: m02 / m00,
        m10: 0.0,
        m11: 1.0 / m11,
        m12: 0.0,
        m13: m12 / m11,
        m20: 0.0,
        m21: 0.0,
        m22: 0.0,
        m23: -1.0,
        m30: 0.0,
        m31: 0.0,
        m32: 1.0 / b,
        m33: a / b,
      }
    }
    Orthographic => {
      let m00 = 2.0 / rl
      let m03 = -(self.right + self.left) / rl
      let m11 = 2.0 / tb
      let m13 = -(self.top + self.bottom) / tb
      {
        m00: 1.0 / m00,
        m01: 0.0,
        m02: 0.0,
        m03: -m03 / m00,
        m10: 0.0,
        m11: 1.0 / m11,
        m12: 0.0,
        m13: -m13 / m11,
        m20: 0.0,
        m21: 0.0,
        m22: 1.0 / a,
        m23: -b / a,
        m30: 0.0,
        m31: 0.0,
        m32: 0.0,
        m33: 1.0,
      }
    }
  }
}

///|
/// NDC depth of a view-space z (negative in front of the camera).
pub fn Projection::ndc_depth(self : Projection, view_z : Double) -> Double {
  let (a, b) = self.depth_coeffs()
  match self.kind {
    Perspective => (a * view_z + b) / -view_z
    Orthographic => a * view_z + b
  }
}

///|
/// View-space z (negative in front) from an NDC depth value.
pub fn Projection::view_z_from_ndc(self : Projection, ndc_z : Double) -> Double {
  let (a, b) = self.depth_coeffs()
  match self.kind {
    Perspective => -b / (ndc_z + a)
    Orthographic => (ndc_z - b) / a
  }
}

///|
/// Distance in front of the camera plane (positive), from an NDC depth value. This is the
/// "linearized depth" used for fog, SSAO, soft particles, etc.
pub fn Projection::linear_depth(self : Projection, ndc_z : Double) -> Double {
  -self.view_z_from_ndc(ndc_z)
}

///|
/// View-space point -> NDC. `None` for perspective points on/behind the eye plane
/// (z_view >= 0), where the projection is not defined for rendering.
pub fn Projection::project_view(self : Projection, p : Vec3) -> Vec3? {
  let (a, b) = self.depth_coeffs()
  let rl = self.right - self.left
  let tb = self.top - self.bottom
  match self.kind {
    Perspective => {
      let w = -p.z
      if !(w > 0.0) {
        return None
      }
      let iw = 1.0 / w
      let n2 = 2.0 * self.near
      Some({
        x: (n2 / rl * p.x + (self.right + self.left) / rl * p.z) * iw,
        y: (n2 / tb * p.y + (self.top + self.bottom) / tb * p.z) * iw,
        z: (a * p.z + b) * iw,
      })
    }
    Orthographic =>
      Some({
        x: (2.0 * p.x - (self.right + self.left)) / rl,
        y: (2.0 * p.y - (self.top + self.bottom)) / tb,
        z: a * p.z + b,
      })
  }
}

///|
/// NDC -> view-space point (exact inverse of `project_view`).
pub fn Projection::unproject_ndc(self : Projection, ndc : Vec3) -> Vec3 {
  let zv = self.view_z_from_ndc(ndc.z)
  let rl = self.right - self.left
  let tb = self.top - self.bottom
  match self.kind {
    Perspective => {
      let m00 = 2.0 * self.near / rl
      let m02 = (self.right + self.left) / rl
      let m11 = 2.0 * self.near / tb
      let m12 = (self.top + self.bottom) / tb
      { x: -zv * (ndc.x + m02) / m00, y: -zv * (ndc.y + m12) / m11, z: zv, }
    }
    Orthographic =>
      {
        x: (ndc.x * rl + (self.right + self.left)) * 0.5,
        y: (ndc.y * tb + (self.top + self.bottom)) * 0.5,
        z: zv,
      }
  }
}

///|
/// View-space ray through an NDC xy position: (origin, unit direction).
///  - perspective: origin = eye (0,0,0)
///  - orthographic: origin lies on the near plane, direction = -Z
pub fn Projection::view_ray(
  self : Projection,
  ndc_x : Double,
  ndc_y : Double,
) -> (Vec3, Vec3) {
  let rl = self.right - self.left
  let tb = self.top - self.bottom
  match self.kind {
    Perspective => {
      let m00 = 2.0 * self.near / rl
      let m02 = (self.right + self.left) / rl
      let m11 = 2.0 * self.near / tb
      let m12 = (self.top + self.bottom) / tb
      let d = Vec3::new((ndc_x + m02) / m00, (ndc_y + m12) / m11, -1.0)
      (Vec3::zero(), d.normalize())
    }
    Orthographic =>
      (
        Vec3::new(
          (ndc_x * rl + (self.right + self.left)) * 0.5,
          (ndc_y * tb + (self.top + self.bottom)) * 0.5,
          -self.near,
        ),
        Vec3::new(0.0, 0.0, -1.0),
      )
  }
}

///|
/// Vertical field of view (radians) of a perspective projection (0 for orthographic).
pub fn Projection::fov_y(self : Projection) -> Double {
  match self.kind {
    Perspective =>
      @math.atan(self.top / self.near) + @math.atan(-self.bottom / self.near)
    Orthographic => 0.0
  }
}

///|
/// Width / height of the projection window.
pub fn Projection::aspect(self : Projection) -> Double {
  (self.right - self.left) / (self.top - self.bottom)
}

///|
/// Same vertical extent, new aspect ratio (keeps relative horizontal lens shift).
pub fn Projection::with_aspect(
  self : Projection,
  aspect : Double,
) -> Projection {
  let h = self.top - self.bottom
  let k = h * aspect / (self.right - self.left)
  { ..self, left: self.left * k, right: self.right * k, }
}

///|
pub fn Projection::with_depth(
  self : Projection,
  depth : DepthMode,
) -> Projection {
  { ..self, depth, }
}

///|
/// Convert an NDC z in any mode to the [0, 1] "window" range (0 = viewport.min_depth).
pub fn DepthMode::to_unit(self : DepthMode, ndc_z : Double) -> Double {
  match self {
    NegOneToOne => ndc_z * 0.5 + 0.5
    _ => ndc_z
  }
}

///|
/// Inverse of `to_unit`.
pub fn DepthMode::from_unit(self : DepthMode, unit_z : Double) -> Double {
  match self {
    NegOneToOne => unit_z * 2.0 - 1.0
    _ => unit_z
  }
}