//! L2 vector normalization.
//!
//! All stored vectors in tidalDB are L2-normalized so that L2 distance on the
//! HNSW index is equivalent to cosine distance. This module provides the
//! normalization function and its correctness tests.

use super::super::VectorError;

/// L2-normalize a vector to unit length.
///
/// Computes `v[i] = v[i] / ||v||` where `||v|| = sqrt(sum(v[i]^2))`.
///
/// For L2-normalized vectors, L2 distance is equivalent to cosine distance:
/// `||a - b||^2 = 2 - 2 * cos(a, b)`.
///
/// # Errors
///
/// Returns [`VectorError::ZeroNormVector`] if the vector has zero norm
/// (all zeros or all components below `f32::EPSILON` in squared sum).
/// A zero vector has no direction and cannot participate in cosine similarity.
///
/// # Post-conditions
///
/// The returned vector has L2 norm within `1e-5` of 1.0.
pub fn l2_normalize(v: &[f32]) -> Result<Vec<f32>, VectorError> {
    let norm_sq: f32 = v.iter().map(|x| x * x).sum();
    if norm_sq < f32::EPSILON {
        return Err(VectorError::ZeroNormVector);
    }
    let norm = norm_sq.sqrt();
    let result: Vec<f32> = v.iter().map(|x| x / norm).collect();

    // Post-condition: verify normalization.
    debug_assert!({
        let result_norm: f32 = result.iter().map(|x| x * x).sum::<f32>().sqrt();
        (1.0 - result_norm).abs() < 1e-5
    });

    Ok(result)
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::cast_precision_loss)]
mod tests {
    use super::*;

    #[test]
    fn l2_normalize_unit_vector() {
        let v = vec![1.0, 0.0, 0.0];
        let normalized = l2_normalize(&v).unwrap();
        assert!((normalized[0] - 1.0).abs() < 1e-6);
        assert!(normalized[1].abs() < 1e-6);
        assert!(normalized[2].abs() < 1e-6);
    }

    #[test]
    fn l2_normalize_non_unit_vector() {
        let v = vec![3.0, 4.0]; // norm = 5
        let normalized = l2_normalize(&v).unwrap();
        assert!((normalized[0] - 0.6).abs() < 1e-5);
        assert!((normalized[1] - 0.8).abs() < 1e-5);
        let norm: f32 = normalized.iter().map(|x| x * x).sum::<f32>().sqrt();
        assert!((1.0 - norm).abs() < 1e-5);
    }

    #[test]
    fn l2_normalize_zero_vector_fails() {
        let v = vec![0.0, 0.0, 0.0];
        let result = l2_normalize(&v);
        assert!(matches!(result, Err(VectorError::ZeroNormVector)));
    }

    #[test]
    fn l2_normalize_near_zero_vector_fails() {
        let v = vec![1e-40, 0.0, 0.0]; // norm^2 < f32::EPSILON
        let result = l2_normalize(&v);
        assert!(matches!(result, Err(VectorError::ZeroNormVector)));
    }

    mod proptests {
        use super::*;
        use proptest::prelude::*;

        proptest! {
            #[test]
            fn normalize_produces_unit_vector(
                v in prop::collection::vec(-100.0f32..100.0, 2..256),
            ) {
                // Skip zero vectors (they fail normalization, which is correct)
                let norm_sq: f32 = v.iter().map(|x| x * x).sum();
                prop_assume!(norm_sq > f32::EPSILON);

                let normalized = l2_normalize(&v).unwrap();
                let result_norm: f32 = normalized.iter().map(|x| x * x).sum::<f32>().sqrt();
                prop_assert!(
                    (1.0 - result_norm).abs() < 1e-5,
                    "norm was {result_norm}, expected ~1.0"
                );
            }

            #[test]
            fn normalize_idempotent(
                v in prop::collection::vec(-100.0f32..100.0, 2..256),
            ) {
                let norm_sq: f32 = v.iter().map(|x| x * x).sum();
                prop_assume!(norm_sq > f32::EPSILON);

                let first = l2_normalize(&v).unwrap();
                let second = l2_normalize(&first).unwrap();

                for (a, b) in first.iter().zip(second.iter()) {
                    prop_assert!((a - b).abs() < 1e-5,
                        "idempotent check failed: {a} vs {b}");
                }
            }

            #[test]
            fn normalize_preserves_direction(
                v in prop::collection::vec(1.0f32..100.0, 2..256),
            ) {
                let normalized = l2_normalize(&v).unwrap();

                // Cosine similarity between v and normalized(v) should be ~1.0
                let dot: f32 = v.iter().zip(normalized.iter()).map(|(a, b)| a * b).sum();
                let norm_v: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
                let cosine = dot / norm_v; // normalized already has norm 1

                prop_assert!(
                    (1.0 - cosine).abs() < 1e-4,
                    "cosine similarity with original was {cosine}, expected ~1.0"
                );
            }
        }
    }
}
