//! A view [`Frustum`]: six planes used for visibility culling.

use crate::math::{Aabb, Plane};
use glam::{Mat4, Vec3, Vec4, Vec4Swizzles};
use serde::{Deserialize, Serialize};

/// A frustum represented by six bounding planes, each with its normal pointing
/// *inward*. A point is inside the frustum when it lies in the positive
/// half-space of every plane.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct Frustum {
    /// Planes ordered: left, right, bottom, top, near, far.
    pub planes: [Plane; 6],
}

impl Frustum {
    /// Extracts the six frustum planes from a combined view-projection matrix
    /// using the Gribb–Hartmann method. Works for both perspective and
    /// orthographic projections.
    pub fn from_view_projection(view_projection: Mat4) -> Self {
        // Rows of the matrix (glam is column-major, so build rows explicitly).
        let m = view_projection;
        let row0 = Vec4::new(m.x_axis.x, m.y_axis.x, m.z_axis.x, m.w_axis.x);
        let row1 = Vec4::new(m.x_axis.y, m.y_axis.y, m.z_axis.y, m.w_axis.y);
        let row2 = Vec4::new(m.x_axis.z, m.y_axis.z, m.z_axis.z, m.w_axis.z);
        let row3 = Vec4::new(m.x_axis.w, m.y_axis.w, m.z_axis.w, m.w_axis.w);

        let plane_from = |v: Vec4| Plane::new(v.xyz(), v.w);

        let planes = [
            plane_from(row3 + row0), // left
            plane_from(row3 - row0), // right
            plane_from(row3 + row1), // bottom
            plane_from(row3 - row1), // top
            plane_from(row3 + row2), // near
            plane_from(row3 - row2), // far
        ];
        Self { planes }
    }

    /// Returns `true` if `point` is inside (or on the boundary of) the frustum.
    pub fn contains_point(&self, point: Vec3) -> bool {
        self.planes
            .iter()
            .all(|plane| plane.signed_distance(point) >= 0.0)
    }

    /// Returns `true` if any part of `aabb` is inside the frustum.
    ///
    /// This is a conservative test: it may very rarely report a box as visible
    /// when it is just outside a corner, but never culls a visible box. That is
    /// the correct trade-off for rendering.
    pub fn intersects_aabb(&self, aabb: &Aabb) -> bool {
        for plane in &self.planes {
            // The "positive vertex": the AABB corner farthest along the normal.
            let p = Vec3::new(
                if plane.normal.x >= 0.0 {
                    aabb.max.x
                } else {
                    aabb.min.x
                },
                if plane.normal.y >= 0.0 {
                    aabb.max.y
                } else {
                    aabb.min.y
                },
                if plane.normal.z >= 0.0 {
                    aabb.max.z
                } else {
                    aabb.min.z
                },
            );
            // If the farthest corner is behind a plane, the box is fully outside.
            if plane.signed_distance(p) < 0.0 {
                return false;
            }
        }
        true
    }

    /// Returns `true` if the sphere at `center` with `radius` is at least
    /// partially inside the frustum.
    pub fn intersects_sphere(&self, center: Vec3, radius: f32) -> bool {
        self.planes
            .iter()
            .all(|plane| plane.signed_distance(center) >= -radius)
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn perspective_vp() -> Mat4 {
        let proj = Mat4::perspective_rh(60_f32.to_radians(), 1.0, 1.0, 100.0);
        let view = Mat4::look_at_rh(Vec3::new(0.0, 0.0, 0.0), Vec3::NEG_Z, Vec3::Y);
        proj * view
    }

    #[test]
    fn point_in_front_is_inside() {
        let f = Frustum::from_view_projection(perspective_vp());
        assert!(f.contains_point(Vec3::new(0.0, 0.0, -10.0)));
    }

    #[test]
    fn point_behind_camera_is_outside() {
        let f = Frustum::from_view_projection(perspective_vp());
        assert!(!f.contains_point(Vec3::new(0.0, 0.0, 10.0)));
    }

    #[test]
    fn point_beyond_far_is_outside() {
        let f = Frustum::from_view_projection(perspective_vp());
        assert!(!f.contains_point(Vec3::new(0.0, 0.0, -500.0)));
    }

    #[test]
    fn point_way_off_to_side_is_outside() {
        let f = Frustum::from_view_projection(perspective_vp());
        assert!(!f.contains_point(Vec3::new(500.0, 0.0, -10.0)));
    }

    #[test]
    fn aabb_in_view_intersects() {
        let f = Frustum::from_view_projection(perspective_vp());
        let bb = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, -10.0), Vec3::splat(1.0));
        assert!(f.intersects_aabb(&bb));
    }

    #[test]
    fn aabb_behind_camera_is_culled() {
        let f = Frustum::from_view_projection(perspective_vp());
        let bb = Aabb::from_center_half_extents(Vec3::new(0.0, 0.0, 50.0), Vec3::splat(1.0));
        assert!(!f.intersects_aabb(&bb));
    }

    #[test]
    fn sphere_culling() {
        let f = Frustum::from_view_projection(perspective_vp());
        assert!(f.intersects_sphere(Vec3::new(0.0, 0.0, -10.0), 1.0));
        // Just behind the camera but large enough to poke into the near plane.
        assert!(!f.intersects_sphere(Vec3::new(0.0, 0.0, 50.0), 1.0));
    }

    #[test]
    fn orthographic_frustum_works() {
        let proj = Mat4::orthographic_rh(-10.0, 10.0, -10.0, 10.0, 1.0, 100.0);
        let view = Mat4::look_at_rh(Vec3::ZERO, Vec3::NEG_Z, Vec3::Y);
        let f = Frustum::from_view_projection(proj * view);
        assert!(f.contains_point(Vec3::new(5.0, 5.0, -10.0)));
        assert!(!f.contains_point(Vec3::new(50.0, 0.0, -10.0)));
    }
}
