// Copyright 2026 Leo Cheng
// SPDX-License-Identifier: Apache-2.0

///|
/// The six planes bounding what a camera can see, each facing inwards.
///
/// A view-projection matrix describes the visible volume as a set of
/// inequalities in clip space; each of the six becomes one plane in world space,
/// and culling is then a signed distance per plane per object. That is the
/// cheapest test that still rejects most of a scene, which is why it is the one
/// every renderer starts with.
///
/// The planes are built for a matrix whose depth range is `0..1`. For an
/// OpenGL-style matrix, whose near plane sits at -1, use `from_matrix_gl`.
pub struct Frustum {
  /// The six planes, in the order left, right, bottom, top, near, far.
  planes : FixedArray[Plane]
}

///|
/// Whether the two hold the same six planes, exactly.
pub impl Eq for Frustum with fn equal(self, other) {
  for i in 0..<6 {
    if self.planes[i] != other.planes[i] {
      return false
    }
  }
  true
}

///|
pub extend Frustum with Eq::{equal, not_equal}

///|
/// The planes of the frustum, in the order left, right, bottom, top, near, far.
///
/// A copy: the array the frustum holds cannot be reached from outside, so a
/// caller cannot break the six-plane invariant by writing into it.
pub fn Frustum::planes(self : Frustum) -> FixedArray[Plane] {
  let copy = FixedArray::make(6, self.planes[0])
  for i in 0..<6 {
    copy[i] = self.planes[i]
  }
  copy
}

///|
/// The plane at `index`, counted from zero in the order left, right, bottom,
/// top, near, far.
pub fn Frustum::plane(self : Frustum, index : Int32) -> Plane {
  self.planes[index]
}

///|
/// The frustum of a view-projection matrix whose depth range is `0..1`, or
/// `None` when the matrix does not describe one.
///
/// The six planes are the six clip-space inequalities pulled back into world
/// space: the left plane is the row `w + x` of the matrix, and so on. A matrix
/// that is singular or degenerate — a projection with no near plane, say — gives
/// a plane with no normal, and there is no frustum to return.
///
/// ```mbt check
/// test {
///   let projection = @moonmath.Mat4::perspective(
///     @moonmath.half_pi, 1.0, 0.1, 100.0,
///   )
///   let frustum = @moonmath.Frustum::from_matrix(projection).unwrap()
///   // A point just in front of the camera is inside, one behind it is not.
///   assert_true(frustum.contains_point(@moonmath.Vec3::new(0.0, 0.0, -1.0)))
///   assert_false(frustum.contains_point(@moonmath.Vec3::new(0.0, 0.0, 1.0)))
/// }
/// ```
pub fn Frustum::from_matrix(m : Mat4) -> Frustum? {
  let r0 = m.row(0)
  let r1 = m.row(1)
  let r2 = m.row(2)
  let r3 = m.row(3)
  Frustum::from_clip_rows(r3 + r0, r3 - r0, r3 + r1, r3 - r1, r2, r3 - r2)
}

///|
/// The frustum of a view-projection matrix whose depth range is `-1..1`.
pub fn Frustum::from_matrix_gl(m : Mat4) -> Frustum? {
  let r0 = m.row(0)
  let r1 = m.row(1)
  let r2 = m.row(2)
  let r3 = m.row(3)
  Frustum::from_clip_rows(r3 + r0, r3 - r0, r3 + r1, r3 - r1, r3 + r2, r3 - r2)
}

///|
/// The six planes from six clip-space rows, normalized, or `None` when one of
/// them names no plane.
fn Frustum::from_clip_rows(
  left : Vec4,
  right : Vec4,
  bottom : Vec4,
  top : Vec4,
  near : Vec4,
  far : Vec4,
) -> Frustum? {
  let rows = [left, right, bottom, top, near, far]
  let planes = FixedArray::make(6, Plane::new(Vec3::unit_z(), 0.0))
  for i in 0..<6 {
    let row = rows[i]
    let normal = row.xyz()
    match normal.try_normalize() {
      None => return None
      Some(unit) => {
        let scale : Float = 1.0 / normal.length()
        planes[i] = { normal: unit, distance: -row.w * scale, }
      }
    }
  }
  Some({ planes, })
}

///|
/// Whether the point is inside all six planes, boundary included.
pub fn Frustum::contains_point(self : Frustum, point : Vec3) -> Bool {
  for i in 0..<6 {
    if self.planes[i].signed_distance(point) < 0.0 {
      return false
    }
  }
  true
}

///|
/// Whether the box lies entirely inside the frustum.
///
/// The corner furthest from each plane — the one the normal points away from —
/// decides: if even that corner is inside, all eight are.
pub fn Frustum::contains_aabb(self : Frustum, box : Aabb3) -> Bool {
  for i in 0..<6 {
    let plane = self.planes[i]
    // The corner the plane's normal points away from: the one that is nearest
    // to being outside. Built by component rather than as a `Vec3`, which would
    // be one heap value per plane.
    let x = if plane.normal.x >= 0.0 { box.min.x } else { box.max.x }
    let y = if plane.normal.y >= 0.0 { box.min.y } else { box.max.y }
    let z = if plane.normal.z >= 0.0 { box.min.z } else { box.max.z }
    let distance = plane.normal.x * x +
      plane.normal.y * y +
      plane.normal.z * z -
      plane.distance
    if distance < 0.0 {
      return false
    }
  }
  true
}

///|
/// Whether the frustum overlaps any part of the box.
///
/// The opposite corner from `contains_aabb`, for the opposite question: a box is
/// out only when it lies wholly behind one plane.
pub fn Frustum::intersects_aabb(self : Frustum, box : Aabb3) -> Bool {
  for i in 0..<6 {
    let plane = self.planes[i]
    // The corner the normal points towards: the last one that can still be
    // inside. When even that one is out, the whole box is behind the plane.
    let x = if plane.normal.x >= 0.0 { box.max.x } else { box.min.x }
    let y = if plane.normal.y >= 0.0 { box.max.y } else { box.min.y }
    let z = if plane.normal.z >= 0.0 { box.max.z } else { box.min.z }
    let distance = plane.normal.x * x +
      plane.normal.y * y +
      plane.normal.z * z -
      plane.distance
    if distance < 0.0 {
      return false
    }
  }
  true
}

///|
/// Whether the sphere lies entirely inside the frustum.
pub fn Frustum::contains_sphere(self : Frustum, sphere : Sphere) -> Bool {
  for i in 0..<6 {
    if self.planes[i].signed_distance(sphere.center) < sphere.radius {
      return false
    }
  }
  true
}

///|
/// Whether the frustum overlaps any part of the sphere.
pub fn Frustum::intersects_sphere(self : Frustum, sphere : Sphere) -> Bool {
  for i in 0..<6 {
    if self.planes[i].signed_distance(sphere.center) < -sphere.radius {
      return false
    }
  }
  true
}

///|
/// Whether two frusta have the same six planes to within `eps`.
pub fn Frustum::approx(self : Frustum, other : Frustum, eps? : Float) -> Bool {
  for i in 0..<6 {
    if !self.planes[i].approx(other.planes[i], eps?) {
      return false
    }
  }
  true
}

///|
pub impl Show for Frustum with fn output(self, logger) {
  logger.write_string("Frustum[")
  for i in 0..<6 {
    if i > 0 {
      logger.write_string(", ")
    }
    self.planes[i].output(logger)
  }
  logger.write_string("]")
}

///|
pub extend Frustum with Show::{output, to_string}

///|
pub impl Debug for Frustum with fn to_repr(self) {
  let children : Array[@debug.Repr] = Array(capacity=6)
  for i in 0..<6 {
    children.push(self.planes[i].to_repr())
  }
  @debug.Repr::ctor("Frustum", [(None, @debug.Repr::array(children))])
}

///|
pub extend Frustum with Debug::{to_repr}