//! Anonymization pipeline for community corpus contributions.
//!
//! This module implements the privacy-preserving transformation of
//! extracted claims into anonymized observations suitable for sharing
//! with the community corpus.
//!
//! # Privacy Guarantees
//!
//! 1. **No file paths**: File, line, and matched_text are stripped
//! 2. **Project wildcarding**: Project names become `*`
//! 3. **Temporal rounding**: Timestamps rounded to hour for k-anonymity
//! 4. **Hash isolation**: anon_hash computed WITHOUT sensitive fields

use blake3::Hasher;

use crate::config::CommunityConfig;
use crate::types::ExtractedClaim;

use super::types::{AnonymizedObservation, CommunityObjectValue};

/// Anonymize a claim for community sharing.
///
/// Returns `None` if the claim should be excluded (by pattern or confidence).
///
/// # Privacy Model
///
/// This function:
/// 1. Checks exclusion patterns (glob-style matching)
/// 2. Checks minimum confidence threshold
/// 3. Wildcards the project name in the subject path
/// 4. Computes anon_hash from (subject, predicate, value) ONLY
/// 5. Rounds timestamp to nearest hour
///
/// The anon_hash specifically excludes file, line, and matched_text
/// to prevent re-identification of the source location.
pub fn anonymize_claim(
    claim: &ExtractedClaim,
    config: &CommunityConfig,
    timestamp: u64,
) -> Option<AnonymizedObservation> {
    // 1. Check minimum confidence
    if claim.confidence < config.min_confidence {
        return None;
    }

    // 2. Check inclusion patterns (if non-empty, only included paths pass)
    if !config.include.is_empty() {
        let matches_include =
            config.include.iter().any(|pattern| path_matches_pattern(&claim.concept_path, pattern));
        if !matches_include {
            return None;
        }
    }

    // 3. Check exclusion patterns
    for pattern in &config.exclude {
        if path_matches_pattern(&claim.concept_path, pattern) {
            return None;
        }
    }

    // 4. Wildcard the project name
    let anonymized_subject = wildcard_project_path(&claim.concept_path);

    // 5. Convert value to community type
    let community_value = CommunityObjectValue::from(&claim.value);

    // 6. Compute anon_hash WITHOUT file/line/matched_text
    let anon_hash = compute_anon_hash(&anonymized_subject, &claim.predicate, &community_value);

    // 7. Round timestamp to nearest hour (3600 seconds)
    let timestamp_hour = (timestamp / 3600) * 3600;

    Some(AnonymizedObservation {
        subject: anonymized_subject,
        predicate: claim.predicate.clone(),
        object: community_value,
        confidence: claim.confidence,
        anon_hash,
        timestamp_hour,
    })
}

/// Wildcard the project-specific path segment.
///
/// # Examples
///
/// ```
/// use aphoria::community::wildcard_project_path;
///
/// assert_eq!(
///     wildcard_project_path("code://rust/myapp/tls/cert_verification"),
///     "code://rust/*/tls/cert_verification"
/// );
/// assert_eq!(
///     wildcard_project_path("code://go/billing-service/db/connection"),
///     "code://go/*/db/connection"
/// );
/// ```
///
/// The function identifies the project segment as the third path component
/// (after scheme and language) and replaces it with `*`.
pub fn wildcard_project_path(path: &str) -> String {
    // Parse: scheme://lang/project/rest...
    // We want to replace "project" with "*"

    if let Some((scheme, rest)) = path.split_once("://") {
        let parts: Vec<&str> = rest.split('/').collect();

        if parts.len() >= 2 {
            // parts[0] = language (rust, go, etc.)
            // parts[1] = project name (myapp, billing-service)
            // parts[2..] = concept path (tls/cert_verification)

            let mut result = format!("{}://{}/*/", scheme, parts[0]);

            // Append the rest of the path after the project segment
            if parts.len() > 2 {
                result.push_str(&parts[2..].join("/"));
            }

            return result;
        }
    }

    // If we can't parse it, return unchanged (shouldn't happen with valid paths)
    path.to_string()
}

/// Compute hash WITHOUT file/line/matched_text.
///
/// CRITICAL: This is the privacy-preserving hash. It includes ONLY:
/// - subject (already wildcarded)
/// - predicate
/// - value
///
/// It specifically EXCLUDES:
/// - file path
/// - line number
/// - matched_text
///
/// This allows server-side deduplication without revealing source locations.
pub fn compute_anon_hash(subject: &str, predicate: &str, value: &CommunityObjectValue) -> [u8; 32] {
    let mut hasher = Hasher::new();
    hasher.update(subject.as_bytes());
    hasher.update(b":");
    hasher.update(predicate.as_bytes());
    hasher.update(b":");
    // Use stable serialization format (not Debug, which could change)
    match value {
        CommunityObjectValue::Boolean(b) => {
            hasher.update(b"bool:");
            hasher.update(if *b { b"true" } else { b"false" });
        }
        CommunityObjectValue::Text(s) => {
            hasher.update(b"text:");
            hasher.update(s.as_bytes());
        }
        CommunityObjectValue::Number(n) => {
            hasher.update(b"number:");
            hasher.update(&n.to_le_bytes());
        }
    }
    *hasher.finalize().as_bytes()
}

/// Check if a path matches a glob-style pattern.
///
/// Supports:
/// - `*` matches any single segment
/// - `**` matches zero or more segments (NOT YET IMPLEMENTED)
/// - Prefix matching: `vendor://acme/` matches all vendor acme paths
fn path_matches_pattern(path: &str, pattern: &str) -> bool {
    // Simple prefix matching (most common case)
    if !pattern.contains('*') {
        return path.starts_with(pattern);
    }

    // For patterns with wildcards, split and match segment by segment
    let path_parts: Vec<&str> = path.split('/').collect();
    let pattern_parts: Vec<&str> = pattern.split('/').collect();

    // Must have at least as many path parts as pattern parts (unless pattern ends with *)
    if path_parts.len() < pattern_parts.len() && !pattern.ends_with('*') {
        return false;
    }

    for (i, pattern_part) in pattern_parts.iter().enumerate() {
        if *pattern_part == "*" {
            // Single segment wildcard - matches anything
            continue;
        }

        if i >= path_parts.len() {
            return false;
        }

        if *pattern_part != path_parts[i] {
            return false;
        }
    }

    true
}

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

    fn make_claim(
        concept_path: &str,
        predicate: &str,
        value: ObjectValue,
        confidence: f32,
    ) -> ExtractedClaim {
        ExtractedClaim {
            concept_path: concept_path.to_string(),
            predicate: predicate.to_string(),
            value,
            file: "src/client.rs".to_string(),
            line: 42,
            matched_text: "danger_accept_invalid_certs(true)".to_string(),
            confidence,
            description: "Test claim".to_string(),
        }
    }

    #[test]
    fn test_wildcard_project_path() {
        assert_eq!(
            wildcard_project_path("code://rust/myapp/tls/cert_verification"),
            "code://rust/*/tls/cert_verification"
        );

        assert_eq!(
            wildcard_project_path("code://go/billing-service/db/connection"),
            "code://go/*/db/connection"
        );

        assert_eq!(
            wildcard_project_path("code://python/ml-pipeline/model/training"),
            "code://python/*/model/training"
        );
    }

    #[test]
    fn test_wildcard_project_path_short_path() {
        // Edge case: path with only scheme and language
        assert_eq!(wildcard_project_path("code://rust"), "code://rust");
    }

    #[test]
    fn test_compute_anon_hash_excludes_file_info() {
        // Two claims with same (subject, predicate, value) but different file/line
        // should produce the same anon_hash
        let subject = "code://rust/*/tls/cert_verification";
        let predicate = "enabled";
        let value = CommunityObjectValue::Boolean(false);

        let hash1 = compute_anon_hash(subject, predicate, &value);
        let hash2 = compute_anon_hash(subject, predicate, &value);

        assert_eq!(hash1, hash2);
    }

    #[test]
    fn test_compute_anon_hash_differs_for_different_values() {
        let subject = "code://rust/*/tls/cert_verification";
        let predicate = "enabled";

        let hash1 = compute_anon_hash(subject, predicate, &CommunityObjectValue::Boolean(true));
        let hash2 = compute_anon_hash(subject, predicate, &CommunityObjectValue::Boolean(false));

        assert_ne!(hash1, hash2);
    }

    #[test]
    fn test_anonymize_claim_basic() {
        let config = CommunityConfig::default();
        let claim = make_claim(
            "code://rust/myapp/tls/cert_verification",
            "enabled",
            ObjectValue::Boolean(false),
            0.95,
        );

        let anon = anonymize_claim(&claim, &config, 1706832000).expect("should anonymize");

        assert_eq!(anon.subject, "code://rust/*/tls/cert_verification");
        assert_eq!(anon.predicate, "enabled");
        assert_eq!(anon.object, CommunityObjectValue::Boolean(false));
        assert_eq!(anon.confidence, 0.95);
    }

    #[test]
    fn test_anonymize_claim_filters_low_confidence() {
        let config = CommunityConfig { min_confidence: 0.9, ..Default::default() };

        let claim = make_claim(
            "code://rust/myapp/tls/cert_verification",
            "enabled",
            ObjectValue::Boolean(false),
            0.7, // Below threshold
        );

        let result = anonymize_claim(&claim, &config, 1000);
        assert!(result.is_none());
    }

    #[test]
    fn test_anonymize_claim_respects_exclude() {
        let config = CommunityConfig {
            exclude: vec!["vendor://acme/internal/".to_string()],
            ..Default::default()
        };

        let claim = make_claim(
            "vendor://acme/internal/secrets",
            "exposed",
            ObjectValue::Boolean(true),
            1.0,
        );

        let result = anonymize_claim(&claim, &config, 1000);
        assert!(result.is_none());
    }

    #[test]
    fn test_anonymize_claim_respects_include_whitelist() {
        let config =
            CommunityConfig { include: vec!["code://rust/".to_string()], ..Default::default() };

        // Rust path should pass
        let rust_claim =
            make_claim("code://rust/myapp/tls/cert", "enabled", ObjectValue::Boolean(false), 0.9);
        assert!(anonymize_claim(&rust_claim, &config, 1000).is_some());

        // Go path should be filtered
        let go_claim =
            make_claim("code://go/myapp/tls/cert", "enabled", ObjectValue::Boolean(false), 0.9);
        assert!(anonymize_claim(&go_claim, &config, 1000).is_none());
    }

    #[test]
    fn test_anonymize_claim_timestamp_rounding() {
        let config = CommunityConfig::default();
        let claim =
            make_claim("code://rust/myapp/tls/cert", "enabled", ObjectValue::Boolean(false), 0.9);

        // 1706832000 is already on the hour
        let anon1 = anonymize_claim(&claim, &config, 1706832000).expect("anon");
        assert_eq!(anon1.timestamp_hour, 1706832000);

        // 1706832500 (500 seconds into the hour) should round down
        let anon2 = anonymize_claim(&claim, &config, 1706832500).expect("anon");
        assert_eq!(anon2.timestamp_hour, 1706832000);

        // 1706835599 (end of hour) should round down to same hour
        let anon3 = anonymize_claim(&claim, &config, 1706835599).expect("anon");
        assert_eq!(anon3.timestamp_hour, 1706832000);

        // 1706835600 (next hour) should round to next hour
        let anon4 = anonymize_claim(&claim, &config, 1706835600).expect("anon");
        assert_eq!(anon4.timestamp_hour, 1706835600);
    }

    #[test]
    fn test_path_matches_pattern_prefix() {
        assert!(path_matches_pattern("vendor://acme/internal/secrets", "vendor://acme/internal/"));
        assert!(!path_matches_pattern(
            "vendor://other/internal/secrets",
            "vendor://acme/internal/"
        ));
    }

    #[test]
    fn test_path_matches_pattern_wildcard() {
        assert!(path_matches_pattern("code://rust/myapp/tls", "code://*/myapp/tls"));
        assert!(path_matches_pattern("code://go/myapp/tls", "code://*/myapp/tls"));
    }

    #[test]
    fn test_anon_hash_differs_from_source_hash() {
        // This is the CRITICAL test: anon_hash and the source_hash used in bridge.rs
        // must be DIFFERENT because source_hash includes file/line/text.

        let claim =
            make_claim("code://rust/myapp/tls/cert", "enabled", ObjectValue::Boolean(false), 1.0);

        // Compute anon_hash (NO file/line/text)
        let wildcarded = wildcard_project_path(&claim.concept_path);
        let community_value = CommunityObjectValue::from(&claim.value);
        let anon_hash = compute_anon_hash(&wildcarded, &claim.predicate, &community_value);

        // Compute what source_hash does (WITH file/line/text) - from bridge.rs
        let mut source_hasher = blake3::Hasher::new();
        source_hasher.update(claim.file.as_bytes());
        source_hasher.update(&claim.line.to_le_bytes());
        source_hasher.update(claim.matched_text.as_bytes());
        let source_hash: [u8; 32] = *source_hasher.finalize().as_bytes();

        // They MUST be different
        assert_ne!(anon_hash, source_hash, "anon_hash must NOT include file/line/text!");
    }
}
