// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: 2026 Torgeir Børresen <tb@starkad.no>
// Rust port of Google's S2 Geometry library — a derivative work, modified from
// the upstream Apache-2.0 source(s) below (Copyright Google Inc.). See LICENSE.
//   - C++:  google/s2geometry
//   - Java: google/s2-geometry-library-java

#![expect(
    clippy::cast_possible_truncation,
    reason = "level (i32) -> u8 after clamping to valid cell level range"
)]
// Snap functions for S2Builder.
//
// A SnapFunction restricts the locations of the output vertices. For
// example, S2CellIdSnapFunction snaps all vertices to S2CellId centers
// at a specified level.

use crate::s1::Angle;
use crate::s2::coords::Level;
use crate::s2::metric;
use crate::s2::predicates::DBL_EPSILON;
use crate::s2::{CellId, LatLng, Point};

/// Maximum snap radius allowed for any snap function. Approximately
/// 70 degrees (~7800 km on Earth).
pub const MAX_SNAP_RADIUS: Angle = Angle::from_radians(70.0 * std::f64::consts::PI / 180.0);

/// A snap function controls how vertices are snapped to discrete locations.
pub trait SnapFunction: Send + std::fmt::Debug {
    /// Maximum distance a vertex can move when snapped.
    fn snap_radius(&self) -> Angle;

    /// Returns the snap destination for the given point.
    fn snap_point(&self, point: Point) -> Point;

    /// Guaranteed minimum distance between distinct output vertices.
    fn min_vertex_separation(&self) -> Angle;

    /// Guaranteed minimum distance from an output edge to any
    /// non-incident output vertex.
    fn min_edge_vertex_separation(&self) -> Angle;

    /// Returns a boxed clone of this snap function.
    fn clone_snap(&self) -> Box<dyn SnapFunction>;
}

// ─── IdentitySnapFunction ───────────────────────────────────────────────────

/// Snaps each vertex to itself (no movement). The snap radius controls
/// the minimum distance between distinct output vertices.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct IdentitySnapFunction {
    snap_radius: Angle,
}

impl IdentitySnapFunction {
    /// Creates a new identity snap function with the given snap radius.
    pub fn new(snap_radius: Angle) -> Self {
        debug_assert!(snap_radius <= MAX_SNAP_RADIUS);
        IdentitySnapFunction { snap_radius }
    }
}

impl SnapFunction for IdentitySnapFunction {
    fn snap_radius(&self) -> Angle {
        self.snap_radius
    }

    fn snap_point(&self, point: Point) -> Point {
        point
    }

    fn min_vertex_separation(&self) -> Angle {
        // Since SnapPoint does not move the input point, output vertices
        // are separated by the full snap_radius.
        self.snap_radius
    }

    fn min_edge_vertex_separation(&self) -> Angle {
        // In the worst case, the edge-vertex separation is half of the
        // vertex separation.
        Angle::from_radians(0.5 * self.snap_radius.radians())
    }

    fn clone_snap(&self) -> Box<dyn SnapFunction> {
        Box::new(self.clone())
    }
}

// ─── S2CellIdSnapFunction ───────────────────────────────────────────────────

/// Snaps vertices to the center of the `S2CellId` at a given level.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct S2CellIdSnapFunction {
    level: Level,
    snap_radius: Angle,
}

impl S2CellIdSnapFunction {
    /// Creates a snap function that snaps to cell centers at the given level.
    /// Uses the minimum snap radius required for that level.
    pub fn new(level: impl Into<Level>) -> Self {
        let level = level.into();
        let snap_radius = Self::min_snap_radius_for_level(level);
        S2CellIdSnapFunction { level, snap_radius }
    }

    /// Creates a snap function with a custom snap radius (must be >= minimum).
    pub fn with_snap_radius(level: impl Into<Level>, snap_radius: Angle) -> Self {
        let level = level.into();
        let min = Self::min_snap_radius_for_level(level);
        let snap_radius = if snap_radius.radians() > min.radians() {
            snap_radius
        } else {
            min
        };
        debug_assert!(snap_radius >= min);
        debug_assert!(snap_radius <= MAX_SNAP_RADIUS);
        S2CellIdSnapFunction { level, snap_radius }
    }

    /// Returns the S2 cell level used for snapping.
    pub fn level(&self) -> Level {
        self.level
    }

    /// Returns the minimum snap radius for a given level.
    /// This is half the maximum cell diagonal at that level, plus a small
    /// error term.
    pub fn min_snap_radius_for_level(level: impl Into<Level>) -> Angle {
        let level = level.into();
        // 0.5 * MaxDiag.value(level) + 4 * DBL_EPSILON
        let max_diag_value = metric::MAX_DIAG.value(level);
        Angle::from_radians(0.5 * max_diag_value + 4.0 * DBL_EPSILON)
    }

    /// Returns the minimum cell level (largest cells) such that vertices
    /// will not move by more than `snap_radius`. This is the inverse of
    /// `min_snap_radius_for_level`. Out-of-range values are silently clamped.
    pub fn level_for_max_snap_radius(snap_radius: Angle) -> Level {
        // Account for the error bound of 4 * DBL_EPSILON added by
        // min_snap_radius_for_level.
        metric::MAX_DIAG.min_level(2.0 * (snap_radius.radians() - 4.0 * DBL_EPSILON))
    }
}

impl SnapFunction for S2CellIdSnapFunction {
    fn snap_radius(&self) -> Angle {
        self.snap_radius
    }

    fn snap_point(&self, point: Point) -> Point {
        CellId::from_point(&point)
            .parent_at_level(self.level)
            .to_point()
    }

    fn min_vertex_separation(&self) -> Angle {
        // Three bounds: constant, proportional, and asymptotic.
        let snap_radius = self.snap_radius.radians();
        let min_edge = metric::MIN_EDGE.value(self.level);
        let max_diag = metric::MAX_DIAG.value(self.level);

        let constant_bound = min_edge;
        let proportional_bound = 0.548 * snap_radius;
        let asymptotic_bound = snap_radius - 0.5 * max_diag;

        Angle::from_radians(constant_bound.max(proportional_bound).max(asymptotic_bound))
    }

    fn min_edge_vertex_separation(&self) -> Angle {
        let snap_radius = self.snap_radius.radians();
        let min_diag = metric::MIN_DIAG.value(self.level);

        // Check if we're at the minimum snap radius for this level.
        let min_sr = Self::min_snap_radius_for_level(self.level).radians();
        if (snap_radius - min_sr).abs() < 1e-15 {
            return Angle::from_radians(0.565 * min_diag);
        }

        let vertex_sep = self.min_vertex_separation().radians();

        let constant_bound = 0.397 * min_diag;
        let proportional_bound = 0.219 * snap_radius;
        let asymptotic_bound = if snap_radius > 0.0 {
            0.5 * (vertex_sep / snap_radius) * vertex_sep
        } else {
            0.0
        };

        Angle::from_radians(constant_bound.max(proportional_bound).max(asymptotic_bound))
    }

    fn clone_snap(&self) -> Box<dyn SnapFunction> {
        Box::new(self.clone())
    }
}

// ─── IntLatLngSnapFunction ──────────────────────────────────────────────────

/// Snaps vertices to E5/E6/E7 (or other exponent) integer lat/lng coordinates.
///
/// The exponent controls the precision: E5 means 5 decimal places of degree
/// precision, E6 means 6, E7 means 7, etc. Valid range: 0 to 10.
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct IntLatLngSnapFunction {
    exponent: i32,
    snap_radius: Angle,
    /// Multiplier: 10^exponent.
    from_degrees: f64,
    /// Divisor: 10^(-exponent) in degrees.
    to_degrees: f64,
}

impl IntLatLngSnapFunction {
    /// The minimum allowed exponent (0, i.e. snapping to integer degrees).
    pub const MIN_EXPONENT: i32 = 0;
    /// The maximum allowed exponent (10).
    pub const MAX_EXPONENT: i32 = 10;

    /// Creates a snap function for the given exponent (0-10).
    /// E5=5, E6=6, E7=7.
    pub fn new(exponent: i32) -> Self {
        debug_assert!(exponent >= Self::MIN_EXPONENT);
        debug_assert!(exponent <= Self::MAX_EXPONENT);
        let exponent = exponent.clamp(Self::MIN_EXPONENT, Self::MAX_EXPONENT);
        let power = 10_f64.powi(exponent);
        let snap_radius = Self::min_snap_radius_for_exponent(exponent);
        IntLatLngSnapFunction {
            exponent,
            snap_radius,
            from_degrees: power,
            to_degrees: 1.0 / power,
        }
    }

    /// Creates a snap function with a custom snap radius.
    pub fn with_snap_radius(exponent: i32, snap_radius: Angle) -> Self {
        debug_assert!(exponent >= Self::MIN_EXPONENT);
        debug_assert!(exponent <= Self::MAX_EXPONENT);
        let exponent = exponent.clamp(Self::MIN_EXPONENT, Self::MAX_EXPONENT);
        let power = 10_f64.powi(exponent);
        let min = Self::min_snap_radius_for_exponent(exponent);
        let snap_radius = if snap_radius.radians() > min.radians() {
            snap_radius
        } else {
            min
        };
        IntLatLngSnapFunction {
            exponent,
            snap_radius,
            from_degrees: power,
            to_degrees: 1.0 / power,
        }
    }

    /// Returns the exponent used for snapping.
    pub fn exponent(&self) -> i32 {
        self.exponent
    }

    /// Returns the minimum snap radius for a given exponent.
    pub fn min_snap_radius_for_exponent(exponent: i32) -> Angle {
        let power = 10_f64.powi(exponent);
        // The rounding error is at most (1/sqrt(2)) * (1/power) degrees.
        // Plus numerical errors from the lat/lng conversion.
        let rounding_error = std::f64::consts::FRAC_1_SQRT_2 / power;
        let numerical_error = (9.0 * std::f64::consts::SQRT_2 + 1.5) * DBL_EPSILON;
        Angle::from_degrees(rounding_error) + Angle::from_radians(numerical_error)
    }

    /// Returns the minimum exponent such that vertices will not move by
    /// more than `snap_radius`. This is the inverse of
    /// `min_snap_radius_for_exponent`. Out-of-range values are silently clamped.
    pub fn exponent_for_max_snap_radius(snap_radius: Angle) -> i32 {
        // Account for the error bound added by min_snap_radius_for_exponent.
        let adjusted = snap_radius.radians() - (9.0 * std::f64::consts::SQRT_2 + 1.5) * DBL_EPSILON;
        let adjusted = adjusted.max(1e-30);
        let exponent = f64::log10(std::f64::consts::FRAC_1_SQRT_2 / adjusted.to_degrees());
        // Subtract a small error tolerance to ensure this is the inverse of
        // min_snap_radius_for_exponent.
        let result = (exponent - 2.0 * DBL_EPSILON).ceil() as i32;
        result.clamp(Self::MIN_EXPONENT, Self::MAX_EXPONENT)
    }
}

impl SnapFunction for IntLatLngSnapFunction {
    fn snap_radius(&self) -> Angle {
        self.snap_radius
    }

    fn snap_point(&self, point: Point) -> Point {
        debug_assert!(self.exponent >= 0); // Make sure the snap function was initialized.
        let ll = LatLng::from_point(point);
        let lat_deg = ll.lat.degrees();
        let lng_deg = ll.lng.degrees();

        // Round lat and lng to integer multiples of to_degrees.
        let lat_rounded = (lat_deg * self.from_degrees).round() * self.to_degrees;
        let lng_rounded = (lng_deg * self.from_degrees).round() * self.to_degrees;

        LatLng::from_degrees(lat_rounded, lng_rounded).to_point()
    }

    fn min_vertex_separation(&self) -> Angle {
        let snap_radius = self.snap_radius.radians();
        // The maximum movement from snapping is (1/sqrt(2)) / power degrees.
        let max_movement =
            Angle::from_degrees(std::f64::consts::FRAC_1_SQRT_2 * self.to_degrees).radians();

        let proportional_bound: f64 = 0.471 * snap_radius;
        let asymptotic_bound: f64 = snap_radius - max_movement;

        Angle::from_radians(proportional_bound.max(asymptotic_bound))
    }

    fn min_edge_vertex_separation(&self) -> Angle {
        let snap_radius = self.snap_radius.radians();
        let to_rad = Angle::from_degrees(self.to_degrees).radians();
        let vertex_sep = self.min_vertex_separation().radians();

        let constant_bound: f64 = 0.277 * to_rad;
        let proportional_bound: f64 = 0.222 * snap_radius;
        let asymptotic_bound = if snap_radius > 0.0 {
            0.5 * (vertex_sep / snap_radius) * vertex_sep
        } else {
            0.0
        };

        Angle::from_radians(constant_bound.max(proportional_bound).max(asymptotic_bound))
    }

    fn clone_snap(&self) -> Box<dyn SnapFunction> {
        Box::new(self.clone())
    }
}

// ─── Tests ──────────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;
    use crate::s2::coords::MAX_CELL_LEVEL;
    use quickcheck_macros::quickcheck;

    #[test]
    fn test_identity_snap_zero_radius() {
        let snap = IdentitySnapFunction::new(Angle::default());
        assert_eq!(snap.snap_radius().radians(), 0.0);
        assert_eq!(snap.min_vertex_separation().radians(), 0.0);
        assert_eq!(snap.min_edge_vertex_separation().radians(), 0.0);

        let p = Point::from_coords(1.0, 0.0, 0.0);
        assert_eq!(snap.snap_point(p), p);
    }

    #[test]
    fn test_identity_snap_nonzero_radius() {
        let snap = IdentitySnapFunction::new(Angle::from_degrees(1.0));
        assert!((snap.snap_radius().degrees() - 1.0).abs() < 1e-10);
        assert!((snap.min_vertex_separation().degrees() - 1.0).abs() < 1e-10);
        assert!((snap.min_edge_vertex_separation().degrees() - 0.5).abs() < 1e-10);
    }

    #[test]
    fn test_cell_id_snap_level_30() {
        let snap = S2CellIdSnapFunction::new(MAX_CELL_LEVEL);
        let p = Point::from_coords(1.0, 2.0, 3.0);
        let snapped = snap.snap_point(p);

        // The snapped point should be the center of a leaf cell.
        let cell_id = CellId::from_point(&snapped);
        assert!(cell_id.is_leaf());
    }

    #[test]
    fn test_cell_id_snap_level_10() {
        let snap = S2CellIdSnapFunction::new(10);
        let p = Point::from_coords(1.0, 0.0, 0.0);
        let snapped = snap.snap_point(p);

        // Should snap to cell center at level 10.
        let cell_id = CellId::from_point(&snapped);
        assert!(cell_id.level() >= 10);

        // The parent at level 10 should give the same point.
        let parent = CellId::from_point(&p).parent_at_level(10);
        assert_eq!(snapped, parent.to_point());
    }

    #[test]
    fn test_cell_id_snap_radius_properties() {
        for level in [0u8, 5, 10, 15, 20, 25, 30] {
            let snap = S2CellIdSnapFunction::new(level);
            let sr = snap.snap_radius();
            let vs = snap.min_vertex_separation();
            let evs = snap.min_edge_vertex_separation();

            // snap_radius >= min_snap_radius_for_level
            assert!(
                sr.radians() >= S2CellIdSnapFunction::min_snap_radius_for_level(level).radians()
            );

            // min_vertex_separation > 0
            assert!(vs.radians() > 0.0, "level {level}: vs={}", vs.radians());

            // min_edge_vertex_separation > 0
            assert!(evs.radians() > 0.0, "level {level}: evs={}", evs.radians());

            // min_vertex_separation <= 2 * snap_radius
            assert!(vs.radians() <= 2.0 * sr.radians() + 1e-15);

            // min_edge_vertex_separation <= min_vertex_separation
            assert!(evs.radians() <= vs.radians() + 1e-15);
        }
    }

    #[test]
    fn test_cell_id_level_for_max_snap_radius() {
        // level_for_max_snap_radius returns the minimum level (largest cells)
        // such that vertices won't move more than snap_radius.
        // It is the inverse of min_snap_radius_for_level.

        // Verify roundtrip for all levels.
        for level in 0..=MAX_CELL_LEVEL {
            let radius = S2CellIdSnapFunction::min_snap_radius_for_level(level);
            assert_eq!(
                S2CellIdSnapFunction::level_for_max_snap_radius(radius),
                level,
                "roundtrip failed for level {level}"
            );
            // Slightly smaller radius → need next finer level.
            let expected = (level + 1).min(MAX_CELL_LEVEL);
            assert_eq!(
                S2CellIdSnapFunction::level_for_max_snap_radius(Angle::from_radians(
                    0.999 * radius.radians()
                )),
                expected,
                "0.999 * radius failed for level {level}"
            );
        }

        // Very large snap radius → level 0 (largest cells suffice).
        assert_eq!(
            S2CellIdSnapFunction::level_for_max_snap_radius(Angle::from_radians(5.0)),
            0
        );
        // Very small snap radius → max level (need finest cells).
        assert_eq!(
            S2CellIdSnapFunction::level_for_max_snap_radius(Angle::from_radians(1e-30)),
            MAX_CELL_LEVEL
        );
    }

    #[test]
    fn test_int_latlng_snap_e7() {
        let snap = IntLatLngSnapFunction::new(7);
        assert_eq!(snap.exponent(), 7);

        let p = LatLng::from_degrees(47.1234567, -122.9876543).to_point();
        let snapped = snap.snap_point(p);
        let ll = LatLng::from_point(snapped);

        // Should be rounded to 7 decimal places.
        let lat_e7 = (ll.lat.degrees() * 1e7).round() as i64;
        let lng_e7 = (ll.lng.degrees() * 1e7).round() as i64;
        assert_eq!(lat_e7, 471234567);
        assert_eq!(lng_e7, -1229876543);
    }

    #[test]
    fn test_int_latlng_snap_e5() {
        let snap = IntLatLngSnapFunction::new(5);
        let p = LatLng::from_degrees(47.12345, -122.98765).to_point();
        let snapped = snap.snap_point(p);
        let ll = LatLng::from_point(snapped);

        let lat_e5 = (ll.lat.degrees() * 1e5).round() as i64;
        let lng_e5 = (ll.lng.degrees() * 1e5).round() as i64;
        assert_eq!(lat_e5, 4712345);
        assert_eq!(lng_e5, -12298765);
    }

    #[test]
    fn test_int_latlng_snap_radius_properties() {
        for exp in [0, 3, 5, 6, 7, 10] {
            let snap = IntLatLngSnapFunction::new(exp);
            let sr = snap.snap_radius();
            let vs = snap.min_vertex_separation();
            let evs = snap.min_edge_vertex_separation();

            assert!(sr.radians() > 0.0, "exp {exp}: sr={}", sr.radians());
            assert!(vs.radians() > 0.0, "exp {exp}: vs={}", vs.radians());
            assert!(evs.radians() > 0.0, "exp {exp}: evs={}", evs.radians());
            assert!(vs.radians() <= 2.0 * sr.radians() + 1e-15);
            assert!(evs.radians() <= vs.radians() + 1e-15);
        }
    }

    #[test]
    fn test_int_latlng_exponent_for_max_snap_radius() {
        // exponent_for_max_snap_radius returns the minimum exponent such that
        // vertices won't move more than snap_radius. Inverse of
        // min_snap_radius_for_exponent.

        // Verify roundtrip for all valid exponents.
        for exp in IntLatLngSnapFunction::MIN_EXPONENT..=IntLatLngSnapFunction::MAX_EXPONENT {
            let radius = IntLatLngSnapFunction::min_snap_radius_for_exponent(exp);
            assert_eq!(
                IntLatLngSnapFunction::exponent_for_max_snap_radius(radius),
                exp,
                "roundtrip failed for exponent {exp}"
            );
            // Slightly smaller radius → need next finer exponent.
            let expected = (exp + 1).min(IntLatLngSnapFunction::MAX_EXPONENT);
            assert_eq!(
                IntLatLngSnapFunction::exponent_for_max_snap_radius(Angle::from_radians(
                    0.999 * radius.radians()
                )),
                expected,
                "0.999 * radius failed for exponent {exp}"
            );
        }

        // Very large snap radius → minimum exponent.
        assert_eq!(
            IntLatLngSnapFunction::exponent_for_max_snap_radius(Angle::from_radians(5.0)),
            IntLatLngSnapFunction::MIN_EXPONENT
        );
        // Very small snap radius → maximum exponent.
        assert_eq!(
            IntLatLngSnapFunction::exponent_for_max_snap_radius(Angle::from_radians(1e-30)),
            IntLatLngSnapFunction::MAX_EXPONENT
        );
    }

    #[test]
    fn test_clone_snap() {
        let snap = S2CellIdSnapFunction::new(15);
        let cloned = snap.clone_snap();
        assert!((cloned.snap_radius().radians() - snap.snap_radius().radians()).abs() < 1e-15);

        let snap2 = IntLatLngSnapFunction::new(7);
        let cloned2 = snap2.clone_snap();
        assert!((cloned2.snap_radius().radians() - snap2.snap_radius().radians()).abs() < 1e-15);
    }

    // ─── Batch 9: Snap point roundtrip tests (from C++) ────────────────

    #[test]
    fn test_cell_id_snap_point_roundtrip() {
        // C++: S2CellIdSnapFunction::SnapPoint
        // Cell centers at any level should snap to themselves.
        use crate::s2::coords::MAX_CELL_LEVEL;
        use crate::s2::testing::random_cell_id_at_level;
        use rand::SeedableRng;
        use rand::rngs::StdRng;

        let mut rng = StdRng::seed_from_u64(42);
        for _iter in 0..1000 {
            for level in 0..=MAX_CELL_LEVEL {
                let f = S2CellIdSnapFunction::new(level);
                let p = random_cell_id_at_level(&mut rng, level).to_point();
                assert_eq!(
                    p,
                    f.snap_point(p),
                    "snap_point should preserve cell center at level {level}"
                );
            }
        }
    }

    #[test]
    fn test_int_latlng_snap_point_roundtrip() {
        // C++: IntLatLngSnapFunction::SnapPoint
        // Points generated via LatLng::from_e5/e6/e7 should snap to themselves.
        use crate::s2::latlng::LatLng;
        use crate::s2::testing::random_point;
        use rand::SeedableRng;
        use rand::rngs::StdRng;

        let mut rng = StdRng::seed_from_u64(43);
        for _iter in 0..1000 {
            let p = random_point(&mut rng);
            let ll = LatLng::from_point(p);

            let p5 = LatLng::from_e5(ll.lat.e5(), ll.lng.e5()).to_point();
            assert_eq!(
                p5,
                IntLatLngSnapFunction::new(5).snap_point(p5),
                "E5 point should snap to itself"
            );

            let p6 = LatLng::from_e6(ll.lat.e6(), ll.lng.e6()).to_point();
            assert_eq!(
                p6,
                IntLatLngSnapFunction::new(6).snap_point(p6),
                "E6 point should snap to itself"
            );

            let p7 = LatLng::from_e7(ll.lat.e7(), ll.lng.e7()).to_point();
            assert_eq!(
                p7,
                IntLatLngSnapFunction::new(7).snap_point(p7),
                "E7 point should snap to itself"
            );

            // E7 point that is NOT an E6 point should NOT snap to itself at E6.
            let p7not6 = LatLng::from_e7(10 * ll.lat.e6() + 1, 10 * ll.lng.e6() + 1).to_point();
            assert_ne!(
                p7not6,
                IntLatLngSnapFunction::new(6).snap_point(p7not6),
                "E7-only point should not snap to itself at E6"
            );
        }
    }

    // ─── Worst-case ratio audit tests (from C++) ─────────────────────────
    //
    // These tests search for S2Cell / lat-lng grid configurations that minimize
    // the vertex-separation and edge-vertex-separation ratios, verifying the
    // hardcoded constants in min_vertex_separation() and
    // min_edge_vertex_separation().

    use crate::s2::cell::Cell;
    use crate::s2::edge_distances::distance_from_segment;
    use crate::s2::point_measures::turn_angle;
    use std::collections::{BTreeMap, BTreeSet};

    // C++ uses 1e-7, but notes debug vs non-debug results differ by 3.88e-8.
    // Our search uses fewer candidates for speed so we accept a wider tolerance.
    // The `#[ignore]`d tests use tight tolerances with more candidates.
    const RATIO_TOLERANCE: f64 = 1e-2;

    fn get_max_vertex_distance_cell(p: Point, id: CellId) -> Angle {
        let cell = Cell::from(id);
        (0..4)
            .map(|i| p.distance(cell.vertex(i)))
            .fold(Angle::default(), |a, b| {
                if a.radians() > b.radians() { a } else { b }
            })
    }

    /// Circumradius of three points on the sphere.
    fn get_circumradius(a: Point, b: Point, c: Point) -> Angle {
        let too_big = Angle::from_radians(std::f64::consts::PI);
        let ta = turn_angle(a, b, c).radians();
        // C's remainder(ta, PI) = ta - round(ta/PI)*PI
        let ieee_rem = ta - (ta / std::f64::consts::PI).round() * std::f64::consts::PI;
        if ieee_rem.abs() < 1e-2 {
            return too_big;
        }
        let a2 = (b - c).0.norm2();
        let b2 = (c - a).0.norm2();
        let c2 = (a - b).0.norm2();
        if a2 > 2.0 || b2 > 2.0 || c2 > 2.0 {
            return too_big;
        }
        let ma = a2 * (b2 + c2 - a2);
        let mb = b2 * (c2 + a2 - b2);
        let mc = c2 * (a2 + b2 - c2);
        let denom = ma + mb + mc;
        if denom.abs() < 1e-30 {
            return too_big;
        }
        let px = (ma * a.x() + mb * b.x() + mc * c.x()) / denom;
        let py = (ma * a.y() + mb * b.y() + mc * c.y()) / denom;
        let pz = (ma * a.z() + mb * b.z() + mc * c.z()) / denom;
        let p = Point::from_coords(px, py, pz);
        p.distance(a)
    }

    /// Returns 2-layer neighbors of a cell (excluding the cell itself).
    /// Matches C++ `GetNeighbors()` exactly.
    fn get_neighbors(id: CellId) -> Vec<CellId> {
        let mut nbrs: Vec<CellId> = vec![id];
        for _layer in 0..2 {
            let mut new_nbrs = Vec::new();
            for &nbr in &nbrs {
                if let Some(n) = nbr.all_neighbors(id.level()) {
                    new_nbrs.extend(n);
                }
            }
            nbrs.extend(new_nbrs);
            nbrs.retain(|&c| c != id);
            nbrs.sort_unstable();
            nbrs.dedup();
        }
        nbrs
    }

    // ── S2CellId: MinVertexSeparation search ────────────────────────────

    fn update_cell_id_min_vertex_sep(id0: CellId, scores: &mut Vec<(f64, CellId)>) {
        let site0 = id0.to_point();
        if let Some(nbrs) = id0.all_neighbors(id0.level()) {
            for id1 in nbrs {
                let site1 = id1.to_point();
                let vertex_sep = site0.distance(site1);
                let max_snap_radius = get_max_vertex_distance_cell(site0, id1);
                if max_snap_radius.radians() <= 0.0 {
                    continue;
                }
                let r = vertex_sep.radians() / max_snap_radius.radians();
                scores.push((r, id0));
            }
        }
    }

    fn get_cell_id_min_vertex_sep(level: u8, best_cells: &mut BTreeSet<CellId>) -> f64 {
        let search_focus = CellId::from_face(0).children()[3];
        let mut scores: Vec<(f64, CellId)> = Vec::new();

        if level == 0 {
            update_cell_id_min_vertex_sep(CellId::from_face(0), &mut scores);
        } else {
            let parents: Vec<CellId> = best_cells.iter().copied().collect();
            for parent in parents {
                let mut child = parent.child_begin();
                let end = parent.child_end();
                while child != end {
                    update_cell_id_min_vertex_sep(child, &mut scores);
                    child = child.next();
                }
            }
        }

        scores.sort_unstable_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
        scores.dedup();
        best_cells.clear();

        let num_to_keep = 300;
        let mut kept = 0;
        for &(_, id) in &scores {
            if (search_focus.contains(id) || id.contains(search_focus)) && best_cells.insert(id) {
                kept += 1;
                if kept >= num_to_keep {
                    break;
                }
            }
        }
        scores.first().map_or(1e10, |s| s.0)
    }

    #[test]
    fn test_cell_id_min_vertex_separation_snap_radius_ratio() {
        // C++: S2CellIdSnapFunction::MinVertexSeparationSnapRadiusRatio
        let mut best_score = 1e10_f64;
        let mut best_cells = BTreeSet::new();
        for level in 0..=MAX_CELL_LEVEL {
            let score = get_cell_id_min_vertex_sep(level, &mut best_cells);
            best_score = best_score.min(score);
        }
        assert!(
            (best_score - 0.548_490_277_027_825).abs() < RATIO_TOLERANCE,
            "S2CellId min_vertex_sep / snap_radius = {best_score:.15}, expected ~0.548490"
        );
    }

    // ── S2CellId: MinEdgeVertexSeparation search ────────────────────────

    fn get_cell_id_min_edge_sep<F>(
        label: &str,
        objective: F,
        level: u8,
        best_cells: &mut BTreeSet<CellId>,
    ) -> f64
    where
        F: Fn(u8, Angle, Angle, Angle) -> f64,
    {
        let search_focus = CellId::from_face(0).children()[3];
        let mut best_scores: BTreeMap<CellId, f64> = BTreeMap::new();

        let parents: Vec<CellId> = best_cells.iter().copied().collect();
        for parent in parents {
            let mut id0 = parent.child_begin_at_level(level);
            let end = parent.child_end_at_level(level);
            while id0 != end {
                let site0 = id0.to_point();
                let nbrs = get_neighbors(id0);
                for &id1 in &nbrs {
                    let site1 = id1.to_point();
                    let max_v1 = get_max_vertex_distance_cell(site0, id1);
                    for &id2 in &nbrs {
                        if id2 <= id1 {
                            continue;
                        }
                        let site2 = id2.to_point();
                        let min_snap_radius = get_circumradius(site0, site1, site2);
                        if min_snap_radius.radians() > MAX_SNAP_RADIUS.radians() {
                            continue;
                        }
                        let max_v2 = get_max_vertex_distance_cell(site0, id2);
                        let max_snap_radius = if max_v1.radians() < max_v2.radians() {
                            max_v1
                        } else {
                            max_v2
                        };
                        if min_snap_radius.radians() > max_snap_radius.radians() {
                            continue;
                        }

                        let edge_sep = distance_from_segment(site0, site1, site2);
                        let score = objective(level, edge_sep, min_snap_radius, max_snap_radius);
                        let entry = best_scores.entry(id0).or_insert(score);
                        if score < *entry {
                            *entry = score;
                        }
                    }
                }
                id0 = id0.next();
            }
        }

        let mut sorted: Vec<(f64, CellId)> = best_scores
            .iter()
            .map(|(&id, &score)| (score, id))
            .collect();
        sorted.sort_unstable_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));

        best_cells.clear();
        let num_to_keep = 100;
        let mut kept = 0;
        for &(_, id) in &sorted {
            // Add the cell AND its neighbors (matching C++).
            let mut to_add = vec![id];
            if let Some(n) = id.all_neighbors(id.level()) {
                to_add.extend(n);
            }
            for nbr in to_add {
                if (search_focus.contains(nbr) || nbr.contains(search_focus))
                    && best_cells.insert(nbr)
                {
                    kept += 1;
                    if kept >= num_to_keep {
                        let _ = label;
                        return sorted.first().map_or(1e10, |s| s.0);
                    }
                }
            }
        }
        let _ = label;
        sorted.first().map_or(1e10, |s| s.0)
    }

    fn get_cell_id_min_edge_sep_all_levels<F>(label: &str, objective: F) -> f64
    where
        F: Fn(u8, Angle, Angle, Angle) -> f64,
    {
        let mut best_score = 1e10_f64;
        let mut best_cells = BTreeSet::new();
        best_cells.insert(CellId::from_face(0));
        for level in 0..=MAX_CELL_LEVEL {
            let score = get_cell_id_min_edge_sep(label, &objective, level, &mut best_cells);
            best_score = best_score.min(score);
        }
        best_score
    }

    #[test]
    #[ignore = "slow worst-case ratio search (~5-10s)"]
    fn test_cell_id_min_edge_vertex_separation_for_level() {
        // C++: S2CellIdSnapFunction::MinEdgeVertexSeparationForLevel
        let score = get_cell_id_min_edge_sep_all_levels(
            "min_sep_for_level",
            |level, edge_sep, _min_sr, _max_sr| {
                let min_diag = metric::MIN_DIAG.value(level);
                edge_sep.radians() / min_diag
            },
        );
        assert!(
            (score - 0.397_359_568_667_803).abs() < RATIO_TOLERANCE,
            "S2CellId min_edge_vertex_sep / kMinDiag = {score:.15}, expected ~0.397360"
        );
    }

    #[test]
    #[ignore = "slow worst-case ratio search (~5-10s)"]
    fn test_cell_id_min_edge_vertex_separation_at_min_snap_radius() {
        // C++: S2CellIdSnapFunction::MinEdgeVertexSeparationAtMinSnapRadius
        let score = get_cell_id_min_edge_sep_all_levels(
            "min_sep_at_min_radius",
            |level, edge_sep, min_sr, _max_sr| {
                let min_radius_at_level = metric::MAX_DIAG.value(level) / 2.0;
                if min_sr.radians() <= (1.0 + 1e-10) * min_radius_at_level {
                    edge_sep.radians() / metric::MIN_DIAG.value(level)
                } else {
                    100.0
                }
            },
        );
        assert!(
            (score - 0.565_298_006_776_224).abs() < RATIO_TOLERANCE,
            "S2CellId min_edge_vertex_sep/kMinDiag at min radius = {score:.15}, expected ~0.565298"
        );
    }

    #[test]
    #[ignore = "slow worst-case ratio search (~5-10s)"]
    fn test_cell_id_min_edge_vertex_separation_snap_radius_ratio() {
        // C++: S2CellIdSnapFunction::MinEdgeVertexSeparationSnapRadiusRatio
        let score = get_cell_id_min_edge_sep_all_levels(
            "min_sep_snap_radius_ratio",
            |_level, edge_sep, _min_sr, max_sr| edge_sep.radians() / max_sr.radians(),
        );
        assert!(
            (score - 0.219_666_695_288_891).abs() < RATIO_TOLERANCE,
            "S2CellId min_edge_vertex_sep / snap_radius = {score:.15}, expected ~0.219667"
        );
    }

    // ── IntLatLng: MinVertexSeparation search ───────────────────────────

    type IntLL = (i64, i64);

    fn intll_is_valid(ll: IntLL, scale: i64) -> bool {
        ll.0.abs() <= scale / 2 && ll.1.abs() <= scale
    }

    fn intll_has_valid_vertices(ll: IntLL, scale: i64) -> bool {
        ll.0.abs() < scale / 2 && ll.1.abs() < scale
    }

    fn intll_rescale(ll: IntLL, factor: f64) -> IntLL {
        (
            (ll.0 as f64 * factor).round() as i64,
            (ll.1 as f64 * factor).round() as i64,
        )
    }

    fn intll_to_point(ll: IntLL, scale: i64) -> Point {
        LatLng::from_radians(
            ll.0 as f64 * (std::f64::consts::PI / scale as f64),
            ll.1 as f64 * (std::f64::consts::PI / scale as f64),
        )
        .to_point()
    }

    fn intll_get_vertex(ll: IntLL, scale: i64, i: usize) -> Point {
        let dlat: i64 = if i == 0 || i == 3 { -1 } else { 1 };
        let dlng: i64 = if i == 0 || i == 1 { -1 } else { 1 };
        let doubled = (2 * ll.0 + dlat, 2 * ll.1 + dlng);
        intll_to_point(doubled, 2 * scale)
    }

    fn intll_max_vertex_dist(p: Point, ll: IntLL, scale: i64) -> Angle {
        (0..4)
            .map(|i| p.distance(intll_get_vertex(ll, scale, i)))
            .fold(Angle::default(), |a, b| {
                if a.radians() > b.radians() { a } else { b }
            })
    }

    fn get_latlng_min_vertex_sep(
        old_scale: i64,
        scale: i64,
        best_configs: &mut BTreeSet<IntLL>,
    ) -> f64 {
        let min_snap_radius_at_scale = Angle::from_radians(
            std::f64::consts::FRAC_1_SQRT_2 * std::f64::consts::PI / scale as f64,
        );
        let mut scores: Vec<(f64, IntLL)> = Vec::new();
        let scale_factor = scale as f64 / old_scale as f64;

        let parents: Vec<IntLL> = best_configs.iter().copied().collect();
        for parent in parents {
            let new_parent = intll_rescale(parent, scale_factor);
            for dlat0 in -7..=7 {
                let ll0 = (new_parent.0 + dlat0, new_parent.1);
                if !intll_is_valid(ll0, scale) || ll0.0 < 0 {
                    continue;
                }
                let site0 = intll_to_point(ll0, scale);
                for dlat1 in 0..=2 {
                    for dlng1 in 0..=5 {
                        let ll1 = (ll0.0 + dlat1, ll0.1 + dlng1);
                        if ll1 == ll0 || !intll_has_valid_vertices(ll1, scale) {
                            continue;
                        }
                        let max_snap_radius = intll_max_vertex_dist(site0, ll1, scale);
                        if max_snap_radius.radians() < min_snap_radius_at_scale.radians() {
                            continue;
                        }
                        let site1 = intll_to_point(ll1, scale);
                        let vertex_sep = site0.distance(site1);
                        let r = vertex_sep.radians() / max_snap_radius.radians();
                        scores.push((r, ll0));
                    }
                }
            }
        }

        scores.sort_unstable_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
        scores.dedup();
        best_configs.clear();
        let num_to_keep = 100;
        let mut kept = 0;
        for &(_, ll) in &scores {
            if best_configs.insert(ll) {
                kept += 1;
                if kept >= num_to_keep {
                    break;
                }
            }
        }
        scores.first().map_or(1e10, |s| s.0)
    }

    #[test]
    fn test_intlatlng_min_vertex_separation_snap_radius_ratio() {
        // C++: IntLatLngSnapFunction::MinVertexSeparationSnapRadiusRatio
        let mut best_score = 1e10_f64;
        let mut best_configs = BTreeSet::new();
        let mut scale: i64 = 18;
        for lat0 in 0..=9 {
            best_configs.insert((lat0, 0_i64));
        }
        for _exp in 0..=10 {
            let score = get_latlng_min_vertex_sep(scale, 10 * scale, &mut best_configs);
            best_score = best_score.min(score);
            scale *= 10;
        }
        assert!(
            (best_score - 0.471_337_477_576_603).abs() < RATIO_TOLERANCE,
            "IntLatLng min_vertex_sep / snap_radius = {best_score:.15}, expected ~0.471337"
        );
    }

    // ── IntLatLng: MinEdgeVertexSeparation search ───────────────────────

    #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
    struct LatLngConfig {
        scale: i64,
        ll0: IntLL,
        ll1: IntLL,
        ll2: IntLL,
    }

    fn get_latlng_min_edge_sep<F>(
        label: &str,
        objective: &F,
        scale: i64,
        best_configs: &mut Vec<LatLngConfig>,
    ) -> f64
    where
        F: Fn(i64, Angle, Angle) -> f64,
    {
        let min_snap_radius_at_scale = Angle::from_radians(
            std::f64::consts::FRAC_1_SQRT_2 * std::f64::consts::PI / scale as f64,
        );
        let mut scores: Vec<(f64, LatLngConfig)> = Vec::new();

        for parent in best_configs.iter() {
            let sf = scale as f64 / parent.scale as f64;
            let pll0 = intll_rescale(parent.ll0, sf);
            let pll1 = intll_rescale(parent.ll1, sf);
            let pll2 = intll_rescale(parent.ll2, sf);

            for dlat0 in -1..=1_i64 {
                let ll0 = (pll0.0 + dlat0, pll0.1);
                if !intll_is_valid(ll0, scale) || ll0.0 < 0 {
                    continue;
                }
                let site0 = intll_to_point(ll0, scale);
                for dlat1 in -1..=1_i64 {
                    for dlng1 in -2..=2_i64 {
                        let ll1 = (pll1.0 + dlat0 + dlat1, pll1.1 + dlng1);
                        if ll1 == ll0 || !intll_has_valid_vertices(ll1, scale) {
                            continue;
                        }
                        if (ll1.0 - ll0.0).abs() > 2 {
                            continue;
                        }
                        let site1 = intll_to_point(ll1, scale);
                        let max_v1 = intll_max_vertex_dist(site0, ll1, scale);

                        for dlat2 in -1..=1_i64 {
                            for dlng2 in -2..=2_i64 {
                                let ll2 = (pll2.0 + dlat0 + dlat2, pll2.1 + dlng2);
                                if !intll_has_valid_vertices(ll2, scale) {
                                    continue;
                                }
                                if (ll2.0 - ll0.0).abs() > 2 {
                                    continue;
                                }
                                if ll2 <= ll1 || ll2.1 < 0 {
                                    continue;
                                }
                                let site2 = intll_to_point(ll2, scale);
                                let min_snap_radius = get_circumradius(site0, site1, site2);
                                if min_snap_radius.radians() > MAX_SNAP_RADIUS.radians() {
                                    continue;
                                }
                                let max_v2 = intll_max_vertex_dist(site0, ll2, scale);
                                let max_snap_radius = if max_v1.radians() < max_v2.radians() {
                                    max_v1
                                } else {
                                    max_v2
                                };
                                if min_snap_radius.radians() > max_snap_radius.radians() {
                                    continue;
                                }
                                if max_snap_radius.radians() < min_snap_radius_at_scale.radians() {
                                    continue;
                                }

                                let edge_sep = distance_from_segment(site0, site1, site2);
                                let score = objective(scale, edge_sep, max_snap_radius);
                                scores.push((
                                    score,
                                    LatLngConfig {
                                        scale,
                                        ll0,
                                        ll1,
                                        ll2,
                                    },
                                ));
                            }
                        }
                    }
                }
            }
        }

        scores.sort_unstable_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
        scores.dedup_by(|a, b| a.1 == b.1);
        let num_to_keep = 500;
        *best_configs = scores.iter().take(num_to_keep).map(|s| s.1).collect();
        let _ = label;
        scores.first().map_or(1e10, |s| s.0)
    }

    fn get_latlng_min_edge_sep_all<F>(label: &str, objective: F) -> f64
    where
        F: Fn(i64, Angle, Angle) -> f64,
    {
        let mut best_score = 1e10_f64;
        let mut best_configs: Vec<LatLngConfig> = Vec::new();
        let initial_scale: i64 = 6;
        let max_lng = initial_scale;
        let max_lat = initial_scale / 2;

        for lat0 in 0..=max_lat {
            for lat1 in (lat0 - 2)..=(max_lat.min(lat0 + 2)) {
                for lng1 in 0..=max_lng {
                    for lat2 in lat1..=(max_lat.min(lat0 + 2)) {
                        for lng2 in 0..=max_lng {
                            let ll0 = (lat0, 0_i64);
                            let ll1 = (lat1, lng1);
                            let ll2 = (lat2, lng2);
                            if ll2 <= ll1 {
                                continue;
                            }
                            best_configs.push(LatLngConfig {
                                scale: initial_scale,
                                ll0,
                                ll1,
                                ll2,
                            });
                        }
                    }
                }
            }
        }

        let mut scale = initial_scale;
        let mut target_scale: i64 = 180;
        for _exp in 0..=10 {
            while scale < target_scale {
                scale = (scale as f64 * 1.8).min(target_scale as f64) as i64;
                let score = get_latlng_min_edge_sep(label, &objective, scale, &mut best_configs);
                if scale == target_scale {
                    best_score = best_score.min(score);
                }
            }
            target_scale *= 10;
        }
        best_score
    }

    #[test]
    #[ignore = "slow worst-case ratio search (~5-10s)"]
    fn test_intlatlng_min_edge_vertex_separation_for_level() {
        // C++: IntLatLngSnapFunction::MinEdgeVertexSeparationForLevel
        let score = get_latlng_min_edge_sep_all("min_sep_for_level", |scale, edge_sep, _max_sr| {
            let e_unit = std::f64::consts::PI / scale as f64;
            edge_sep.radians() / e_unit
        });
        assert!(
            (score - 0.277_258_917_722_462).abs() < RATIO_TOLERANCE,
            "IntLatLng min_edge_vertex_sep / e_unit = {score:.15}, expected ~0.277259"
        );
    }

    #[test]
    #[ignore = "slow worst-case ratio search (~5-10s)"]
    fn test_intlatlng_min_edge_vertex_separation_snap_radius_ratio() {
        // C++: IntLatLngSnapFunction::MinEdgeVertexSeparationSnapRadiusRatio
        let score =
            get_latlng_min_edge_sep_all("min_sep_snap_radius_ratio", |_scale, edge_sep, max_sr| {
                edge_sep.radians() / max_sr.radians()
            });
        assert!(
            (score - 0.222_222_126_756_717).abs() < RATIO_TOLERANCE,
            "IntLatLng min_edge_vertex_sep / snap_radius = {score:.15}, expected ~0.222222"
        );
    }

    // ─── Property tests ─────────────────────────────────────────────────

    /// Helper: make a valid unit-length Point from arbitrary i32 coords.
    /// Returns None if the coords produce degenerate input.
    fn make_test_point(x: i32, y: i32, z: i32) -> Option<Point> {
        let (xf, yf, zf) = (f64::from(x), f64::from(y), f64::from(z));
        let norm = (xf * xf + yf * yf + zf * zf).sqrt();
        if norm < 1e-10 {
            return None;
        }
        Some(Point::from_coords(xf / norm, yf / norm, zf / norm))
    }

    // ── IdentitySnapFunction properties ─────────────────────────────────

    /// Identity snap never moves any point.
    #[quickcheck]
    fn prop_identity_snap_is_identity(x: i32, y: i32, z: i32) -> bool {
        let Some(p) = make_test_point(x, y, z) else {
            return true;
        };
        let snap = IdentitySnapFunction::new(Angle::from_degrees(1.0));
        snap.snap_point(p) == p
    }

    /// Identity snap: `min_vertex_separation` equals `snap_radius`.
    #[quickcheck]
    fn prop_identity_separation_equals_radius(r_deg: u32) -> bool {
        let r = f64::from(r_deg % 70); // keep in valid range
        let snap = IdentitySnapFunction::new(Angle::from_degrees(r));
        (snap.min_vertex_separation().radians() - snap.snap_radius().radians()).abs() < 1e-15
    }

    /// Identity snap: `min_edge_vertex_separation` = 0.5 * `snap_radius`.
    #[quickcheck]
    fn prop_identity_edge_sep_half_radius(r_deg: u32) -> bool {
        let r = f64::from(r_deg % 70);
        let snap = IdentitySnapFunction::new(Angle::from_degrees(r));
        (snap.min_edge_vertex_separation().radians() - 0.5 * snap.snap_radius().radians()).abs()
            < 1e-15
    }

    // ── S2CellIdSnapFunction properties ─────────────────────────────────

    /// Cell ID snap is idempotent — snapping an already-snapped point gives
    /// the same point.
    #[quickcheck]
    fn prop_cell_id_snap_idempotent(x: i32, y: i32, z: i32, level: u8) -> bool {
        let Some(p) = make_test_point(x, y, z) else {
            return true;
        };
        let level = level % (MAX_CELL_LEVEL + 1);
        let snap = S2CellIdSnapFunction::new(level);
        let snapped = snap.snap_point(p);
        snap.snap_point(snapped) == snapped
    }

    /// Cell ID snap — result is always a valid cell center at the correct level.
    #[quickcheck]
    fn prop_cell_id_snap_is_cell_center(x: i32, y: i32, z: i32, level: u8) -> bool {
        let Some(p) = make_test_point(x, y, z) else {
            return true;
        };
        let level = level % (MAX_CELL_LEVEL + 1);
        let snap = S2CellIdSnapFunction::new(level);
        let snapped = snap.snap_point(p);
        let expected = CellId::from_point(&p).parent_at_level(level).to_point();
        snapped == expected
    }

    /// Cell ID snap — distance from input to snapped point is at most
    /// `snap_radius`.
    #[quickcheck]
    fn prop_cell_id_snap_within_radius(x: i32, y: i32, z: i32, level: u8) -> bool {
        let Some(p) = make_test_point(x, y, z) else {
            return true;
        };
        let level = level % (MAX_CELL_LEVEL + 1);
        let snap = S2CellIdSnapFunction::new(level);
        let snapped = snap.snap_point(p);
        let dist = p.distance(snapped);
        dist.radians() <= snap.snap_radius().radians() + 1e-15
    }

    /// Cell ID snap — separation invariants hold for all levels.
    /// `0 < evs <= vs <= 2 * snap_radius`.
    #[quickcheck]
    fn prop_cell_id_separation_ordering(level: u8) -> bool {
        let level = level % (MAX_CELL_LEVEL + 1);
        let snap = S2CellIdSnapFunction::new(level);
        let sr = snap.snap_radius().radians();
        let vs = snap.min_vertex_separation().radians();
        let evs = snap.min_edge_vertex_separation().radians();
        vs > 0.0 && evs > 0.0 && evs <= vs + 1e-15 && vs <= 2.0 * sr + 1e-15
    }

    /// Cell ID snap — `min_snap_radius_for_level` is monotonically decreasing.
    #[quickcheck]
    fn prop_cell_id_min_snap_radius_monotone(level: u8) -> bool {
        let level = level % MAX_CELL_LEVEL; // 0..29
        let r_low = S2CellIdSnapFunction::min_snap_radius_for_level(level);
        let r_high = S2CellIdSnapFunction::min_snap_radius_for_level(level + 1);
        r_low.radians() >= r_high.radians()
    }

    /// Cell ID snap — two distinct snapped points at the same level are
    /// separated by at least `min_vertex_separation`.
    #[quickcheck]
    fn prop_cell_id_distinct_points_separated(
        x1: i32,
        y1: i32,
        z1: i32,
        x2: i32,
        y2: i32,
        z2: i32,
        level: u8,
    ) -> bool {
        let (Some(p1), Some(p2)) = (make_test_point(x1, y1, z1), make_test_point(x2, y2, z2))
        else {
            return true;
        };
        let level = level % (MAX_CELL_LEVEL + 1);
        let snap = S2CellIdSnapFunction::new(level);
        let s1 = snap.snap_point(p1);
        let s2 = snap.snap_point(p2);
        if s1 == s2 {
            return true; // same site, skip
        }
        let dist = s1.distance(s2).radians();
        let min_sep = snap.min_vertex_separation().radians();
        dist >= min_sep - 1e-15
    }

    // ── IntLatLngSnapFunction properties ────────────────────────────────

    /// `IntLatLng` snap is idempotent.
    #[quickcheck]
    fn prop_int_latlng_snap_idempotent(x: i32, y: i32, z: i32, exp: u8) -> bool {
        let Some(p) = make_test_point(x, y, z) else {
            return true;
        };
        let exp = i32::from(exp) % (IntLatLngSnapFunction::MAX_EXPONENT + 1);
        let snap = IntLatLngSnapFunction::new(exp);
        let snapped = snap.snap_point(p);
        let snapped2 = snap.snap_point(snapped);
        // Due to floating-point, check closeness rather than exact equality.
        snapped.distance(snapped2).radians() < 1e-12
    }

    /// `IntLatLng` snap — result coordinates are integer multiples of
    /// 10^(-exponent) degrees.
    #[quickcheck]
    fn prop_int_latlng_snap_coordinates_integral(x: i32, y: i32, z: i32, exp: u8) -> bool {
        let Some(p) = make_test_point(x, y, z) else {
            return true;
        };
        let exp = i32::from(exp) % (IntLatLngSnapFunction::MAX_EXPONENT + 1);
        let snap = IntLatLngSnapFunction::new(exp);
        let snapped = snap.snap_point(p);
        let ll = LatLng::from_point(snapped);
        let power = 10_f64.powi(exp);
        let lat_int = (ll.lat.degrees() * power).round();
        let lng_int = (ll.lng.degrees() * power).round();
        let lat_back = lat_int / power;
        let lng_back = lng_int / power;
        (ll.lat.degrees() - lat_back).abs() < 1e-10 && (ll.lng.degrees() - lng_back).abs() < 1e-10
    }

    /// `IntLatLng` snap — distance from input to snapped point is at most
    /// `snap_radius`.
    #[quickcheck]
    fn prop_int_latlng_snap_within_radius(x: i32, y: i32, z: i32, exp: u8) -> bool {
        let Some(p) = make_test_point(x, y, z) else {
            return true;
        };
        let exp = i32::from(exp) % (IntLatLngSnapFunction::MAX_EXPONENT + 1);
        let snap = IntLatLngSnapFunction::new(exp);
        let snapped = snap.snap_point(p);
        let dist = p.distance(snapped);
        dist.radians() <= snap.snap_radius().radians() + 1e-15
    }

    /// `IntLatLng` snap — separation invariants hold for all exponents.
    #[quickcheck]
    fn prop_int_latlng_separation_ordering(exp: u8) -> bool {
        let exp = i32::from(exp) % (IntLatLngSnapFunction::MAX_EXPONENT + 1);
        let snap = IntLatLngSnapFunction::new(exp);
        let sr = snap.snap_radius().radians();
        let vs = snap.min_vertex_separation().radians();
        let evs = snap.min_edge_vertex_separation().radians();
        vs > 0.0 && evs > 0.0 && evs <= vs + 1e-15 && vs <= 2.0 * sr + 1e-15
    }

    /// `IntLatLng` snap — `min_snap_radius_for_exponent` is monotonically
    /// decreasing as exponent increases.
    #[quickcheck]
    fn prop_int_latlng_min_snap_radius_monotone(exp: u8) -> bool {
        let exp = i32::from(exp) % IntLatLngSnapFunction::MAX_EXPONENT; // 0..9
        let r_low = IntLatLngSnapFunction::min_snap_radius_for_exponent(exp);
        let r_high = IntLatLngSnapFunction::min_snap_radius_for_exponent(exp + 1);
        r_low.radians() >= r_high.radians()
    }

    // ── Level/Exponent roundtrip properties ─────────────────────────────

    /// `level_for_max_snap_radius` is monotonically non-decreasing as snap
    /// radius decreases (smaller radius → higher level).
    #[quickcheck]
    fn prop_level_for_max_snap_radius_monotone(a_deg: u32, b_deg: u32) -> bool {
        // Use millidegrees to avoid huge ranges
        let r1 = Angle::from_degrees(0.001 * (f64::from(a_deg % 70_000) + 0.001));
        let r2 = Angle::from_degrees(0.001 * (f64::from(b_deg % 70_000) + 0.001));
        let l1 = S2CellIdSnapFunction::level_for_max_snap_radius(r1);
        let l2 = S2CellIdSnapFunction::level_for_max_snap_radius(r2);
        if r1.radians() >= r2.radians() {
            l1 <= l2 // larger radius → same or lower level
        } else {
            l2 <= l1
        }
    }

    /// `exponent_for_max_snap_radius` is monotonically non-decreasing as snap
    /// radius decreases.
    #[quickcheck]
    fn prop_exponent_for_max_snap_radius_monotone(a_deg: u32, b_deg: u32) -> bool {
        let r1 = Angle::from_degrees(0.001 * (f64::from(a_deg % 70_000) + 0.001));
        let r2 = Angle::from_degrees(0.001 * (f64::from(b_deg % 70_000) + 0.001));
        let e1 = IntLatLngSnapFunction::exponent_for_max_snap_radius(r1);
        let e2 = IntLatLngSnapFunction::exponent_for_max_snap_radius(r2);
        if r1.radians() >= r2.radians() {
            e1 <= e2
        } else {
            e2 <= e1
        }
    }

    #[cfg(feature = "serde")]
    #[test]
    fn test_serde_identity_snap_roundtrip() {
        let sf = IdentitySnapFunction::new(Angle::from_degrees(1.0));
        let json = serde_json::to_string(&sf).unwrap();
        let back: IdentitySnapFunction = serde_json::from_str(&json).unwrap();
        assert_eq!(sf.snap_radius(), back.snap_radius());
    }

    #[cfg(feature = "serde")]
    #[test]
    fn test_serde_cell_id_snap_roundtrip() {
        let sf = S2CellIdSnapFunction::new(10);
        let json = serde_json::to_string(&sf).unwrap();
        let back: S2CellIdSnapFunction = serde_json::from_str(&json).unwrap();
        assert_eq!(sf.snap_radius(), back.snap_radius());
        assert_eq!(sf.level(), back.level());
    }

    #[cfg(feature = "serde")]
    #[test]
    fn test_serde_int_latlng_snap_roundtrip() {
        let sf = IntLatLngSnapFunction::new(7);
        let json = serde_json::to_string(&sf).unwrap();
        let back: IntLatLngSnapFunction = serde_json::from_str(&json).unwrap();
        assert_eq!(sf.exponent(), back.exponent());
        // snap_radius may have tiny float round-trip differences via JSON
        assert!((sf.snap_radius().radians() - back.snap_radius().radians()).abs() < 1e-20);
    }
}

#[cfg(test)]
#[path = "snap_tests.rs"]
mod snap_tests;
