//! Syn No Layer - Memory Sanitization and Privacy Redactor.
//!
//! Provides heuristic and regex-based sanitizers to redact sensitive credentials,
//! API keys, emails, and phone numbers from raw text logs before embedding or transmission.

use regex::Regex;
use std::path::PathBuf;
use std::sync::Mutex;

/// A dynamic gazetteer of sensitive entities for non-ML checks.
///
/// WHY: Holds a persistent and runtime-updatable list of entities (like names, locations, orgs) to redact without ML.
///
/// CONTRACT: Safe to load/save from disk and access concurrently.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, Default)]
pub struct Gazetteer {
    /// The registered entities to redact, represented as (Entity, Replacement Placeholder) pairs.
    pub entries: Vec<(String, String)>,
}

impl Gazetteer {
    /// Loads a gazetteer from a file, returning a default empty one on error or if the file doesn't exist.
    ///
    /// WHY: Restores the gazetteer state from disk on startup.
    ///
    /// CONTRACT: Safe to call with any path. Returns a default instance on failure.
    pub fn load(path: &PathBuf) -> Self {
        if path.exists()
            && let Ok(file) = std::fs::File::open(path)
            && let Ok(gaz) = serde_json::from_reader(file)
        {
            return gaz;
        }
        Self::default()
    }

    /// Saves the gazetteer to a file.
    ///
    /// WHY: Persists new entities discovered by the LLM back to disk.
    ///
    /// CONTRACT: Overwrites the file at the path. Returns an IO result.
    pub fn save(&self, path: &PathBuf) -> std::io::Result<()> {
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        let file = std::fs::File::create(path)?;
        serde_json::to_writer_pretty(file, self)?;
        Ok(())
    }
}

/// SynNo - The memory sanitization redactor.
///
/// WHY: This struct encapsulates compiled regular expressions and a dynamic gazetteer used to sanitize
/// PII, credentials, and HIPAA Protected Health Information (PHI) from raw texts.
///
/// CONTRACT: Re-compiling regexes is expensive. Users should construct this once and reuse it across multiple redaction cycles.
pub struct SynNo {
    email_regex: Regex,
    api_key_regex: Regex,
    phone_regex: Regex,
    aws_key_regex: Regex,
    jwt_regex: Regex,
    ssn_regex: Regex,
    zip_regex: Regex,
    ip_regex: Regex,
    url_regex: Regex,
    mrn_regex: Regex,
    date_regex: Regex,
    gazetteer_path: Option<PathBuf>,
    gazetteer: Mutex<Gazetteer>,
}

impl SynNo {
    /// Creates a new SynNo redactor and initializes standard privacy patterns.
    ///
    /// WHY: Instantiates and pre-compiles regex patterns for PII, credentials, and HIPAA Safe Harbor compliance.
    ///
    /// CONTRACT: Returns a valid SynNo instance. Will panic if any regex pattern is syntactically invalid.
    pub fn new() -> Self {
        Self::new_with_gazetteer(None)
    }

    /// Creates a new SynNo redactor and initializes standard privacy patterns with an optional gazetteer path.
    ///
    /// WHY: Instantiates regex patterns and loads the gazetteer from disk if a path is specified.
    ///
    /// CONTRACT: Returns a valid SynNo instance.
    pub fn new_with_gazetteer(gazetteer_path: Option<PathBuf>) -> Self {
        let gazetteer = if let Some(ref path) = gazetteer_path {
            Gazetteer::load(path)
        } else {
            Gazetteer::default()
        };

        Self {
            email_regex: Regex::new(r"(?i)[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}").unwrap(),
            // Matches typical API keys: key = 'value' or key = "value" (8+ char tokens)
            // Uses r## delimiter so we can use literal " inside the pattern.
            api_key_regex: Regex::new(
                r##"(?i)(api_key|apikey|secret|token|password|passwd|private_key|privatekey)\s*[:=]\s*["']?[a-zA-Z0-9_.\-]{8,}["']?"##,
            )
            .unwrap(),
            phone_regex: Regex::new(r"\+?[0-9][0-9\-\s()]{7,15}[0-9]").unwrap(),
            // AWS access key ID pattern: AKIA followed by 16 uppercase alphanumeric chars
            aws_key_regex: Regex::new(r"AKIA[0-9A-Z]{16}").unwrap(),
            // JWT token pattern: three base64url segments separated by dots
            jwt_regex: Regex::new(r"eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+").unwrap(),
            ssn_regex: Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap(),
            zip_regex: Regex::new(r"\b\d{5}(-\d{4})?\b").unwrap(),
            ip_regex: Regex::new(r"\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b").unwrap(),
            url_regex: Regex::new(r"(?i)\bhttps?://[a-zA-Z0-9.\-_]+(?:\.[a-zA-Z]{2,})+(?:/[^\s]*)?\b").unwrap(),
            mrn_regex: Regex::new(r"(?i)\bmrn\s*[:=-]?\s*([a-zA-Z0-9\-]{4,12})\b").unwrap(),
            date_regex: Regex::new(r"\b(?:\d{1,2}/\d{1,2}/\d{4}|\d{4}-\d{1,2}-\d{1,2})\b").unwrap(),
            gazetteer_path,
            gazetteer: Mutex::new(gazetteer),
        }
    }

    /// Redacts all identified sensitive patterns in the input text with a placeholder.
    ///
    /// WHY: Strips credentials, PII, and HIPAA Protected Health Information (PHI) to prevent exposure.
    ///
    /// CONTRACT: Returns a redacted copy of the text. Standard Safe Harbor rules apply.
    pub fn redact(&self, text: &str) -> String {
        let mut sanitized = text.to_string();

        // 1. Redact URLs (must be first to avoid sub-matching of IPs, ZIPs, or dates within URL structure)
        sanitized = self
            .url_regex
            .replace_all(&sanitized, "[REDACTED_URL]")
            .into_owned();

        // 2. Apply Gazetteer entries (non-ML check)
        if let Ok(gaz) = self.gazetteer.lock() {
            for (entity, placeholder) in &gaz.entries {
                if !entity.is_empty() {
                    let escaped = regex::escape(entity);
                    if let Ok(re) = Regex::new(&format!(r"(?i)\b{}\b", escaped)) {
                        sanitized = re
                            .replace_all(&sanitized, placeholder.as_str())
                            .into_owned();
                    } else {
                        sanitized = sanitized.replace(entity, placeholder);
                    }
                }
            }
        }

        // 3. Redact emails
        sanitized = self
            .email_regex
            .replace_all(&sanitized, "[REDACTED_EMAIL]")
            .into_owned();

        // 3. Redact SSN
        sanitized = self
            .ssn_regex
            .replace_all(&sanitized, "[REDACTED_SSN]")
            .into_owned();

        // 4. Redact ZIP codes
        sanitized = self
            .zip_regex
            .replace_all(&sanitized, "[REDACTED_ZIP]")
            .into_owned();

        // 5. Redact IP addresses
        sanitized = self
            .ip_regex
            .replace_all(&sanitized, "[REDACTED_IP]")
            .into_owned();

        // 6. Redact MRNs
        sanitized = self
            .mrn_regex
            .replace_all(&sanitized, "[REDACTED_MRN]")
            .into_owned();

        // 7. Redact dates
        sanitized = self
            .date_regex
            .replace_all(&sanitized, "[REDACTED_DATE]")
            .into_owned();

        // 8. Redact phone numbers (must run after specific patterns like ZIP-9, SSN, and dates to avoid eager collisions)
        sanitized = self
            .phone_regex
            .replace_all(&sanitized, "[REDACTED_PHONE]")
            .into_owned();

        // 9. Redact AWS access key IDs
        sanitized = self
            .aws_key_regex
            .replace_all(&sanitized, "[REDACTED_AWS_KEY]")
            .into_owned();

        // 10. Redact JWT tokens
        sanitized = self
            .jwt_regex
            .replace_all(&sanitized, "[REDACTED_JWT]")
            .into_owned();

        // 11. Redact credentials/API keys (pattern: key = 'value')
        // This must come AFTER AWS/JWT/PHI redaction to avoid double-matching or false positives
        sanitized = self
            .api_key_regex
            .replace_all(&sanitized, "$1: [REDACTED_SECRET]")
            .into_owned();

        sanitized
    }

    /// Redacts and returns a count of how many redactions were applied per category.
    ///
    /// WHY: Performs redaction while exposing fine-grained telemetry on matches.
    ///
    /// CONTRACT: Returns the fully redacted string and populated RedactionStats struct.
    pub fn redact_with_stats(&self, text: &str) -> (String, RedactionStats) {
        let mut stats = RedactionStats::default();
        let mut sanitized = text.to_string();

        stats.urls = self.url_regex.find_iter(&sanitized).count();
        sanitized = self
            .url_regex
            .replace_all(&sanitized, "[REDACTED_URL]")
            .into_owned();

        // Apply Gazetteer entries (non-ML check) and count matches
        if let Ok(gaz) = self.gazetteer.lock() {
            for (entity, placeholder) in &gaz.entries {
                if !entity.is_empty() {
                    let escaped = regex::escape(entity);
                    if let Ok(re) = Regex::new(&format!(r"(?i)\b{}\b", escaped)) {
                        stats.gazetteer += re.find_iter(&sanitized).count();
                        sanitized = re
                            .replace_all(&sanitized, placeholder.as_str())
                            .into_owned();
                    } else {
                        // fallback literal matching
                        if sanitized.contains(entity) {
                            stats.gazetteer += 1;
                            sanitized = sanitized.replace(entity, placeholder);
                        }
                    }
                }
            }
        }

        stats.emails = self.email_regex.find_iter(&sanitized).count();
        sanitized = self
            .email_regex
            .replace_all(&sanitized, "[REDACTED_EMAIL]")
            .into_owned();

        stats.ssns = self.ssn_regex.find_iter(&sanitized).count();
        sanitized = self
            .ssn_regex
            .replace_all(&sanitized, "[REDACTED_SSN]")
            .into_owned();

        stats.zips = self.zip_regex.find_iter(&sanitized).count();
        sanitized = self
            .zip_regex
            .replace_all(&sanitized, "[REDACTED_ZIP]")
            .into_owned();

        stats.ips = self.ip_regex.find_iter(&sanitized).count();
        sanitized = self
            .ip_regex
            .replace_all(&sanitized, "[REDACTED_IP]")
            .into_owned();

        stats.mrns = self.mrn_regex.find_iter(&sanitized).count();
        sanitized = self
            .mrn_regex
            .replace_all(&sanitized, "[REDACTED_MRN]")
            .into_owned();

        stats.dates = self.date_regex.find_iter(&sanitized).count();
        sanitized = self
            .date_regex
            .replace_all(&sanitized, "[REDACTED_DATE]")
            .into_owned();

        stats.phones = self.phone_regex.find_iter(&sanitized).count();
        sanitized = self
            .phone_regex
            .replace_all(&sanitized, "[REDACTED_PHONE]")
            .into_owned();

        stats.aws_keys = self.aws_key_regex.find_iter(&sanitized).count();
        sanitized = self
            .aws_key_regex
            .replace_all(&sanitized, "[REDACTED_AWS_KEY]")
            .into_owned();

        stats.jwts = self.jwt_regex.find_iter(&sanitized).count();
        sanitized = self
            .jwt_regex
            .replace_all(&sanitized, "[REDACTED_JWT]")
            .into_owned();

        stats.credentials = self.api_key_regex.find_iter(&sanitized).count();
        sanitized = self
            .api_key_regex
            .replace_all(&sanitized, "$1: [REDACTED_SECRET]")
            .into_owned();

        (sanitized, stats)
    }

    /// Runs the hybrid de-identification pipeline: non-ML (regex + gazetteer), then LLM, then updates the gazetteer.
    ///
    /// WHY: Ensures high recall by using the LLM for unstructured terms, and accelerates future passes by updating the gazetteer.
    ///
    /// CONTRACT: Returns the fully redacted string.
    pub fn redact_hybrid(&self, text: &str, backend: &crate::EmbeddingBackend) -> String {
        // Step 1: Run non-ML redaction (regexes + existing gazetteer)
        let mut sanitized = self.redact(text);

        // Step 2: Run LLM check to detect any residual unstructured PHI/PII
        let new_entities = backend.extract_phi_entities(&sanitized);

        if !new_entities.is_empty() {
            // Apply the new redactions
            for (entity, placeholder) in &new_entities {
                if !entity.is_empty() {
                    let escaped = regex::escape(entity);
                    if let Ok(re) = Regex::new(&format!(r"(?i)\b{}\b", escaped)) {
                        sanitized = re
                            .replace_all(&sanitized, placeholder.as_str())
                            .into_owned();
                    } else {
                        sanitized = sanitized.replace(entity, placeholder);
                    }
                }
            }

            // Step 3: Update the gazetteer and persist it to disk
            if let Ok(mut gaz) = self.gazetteer.lock() {
                let mut updated = false;
                for (entity, placeholder) in new_entities {
                    if !entity.is_empty()
                        && !gaz
                            .entries
                            .iter()
                            .any(|(e, _)| e.eq_ignore_ascii_case(&entity))
                    {
                        gaz.entries.push((entity, placeholder));
                        updated = true;
                    }
                }
                if updated
                    && let Some(ref path) = self.gazetteer_path
                    && let Err(e) = gaz.save(path)
                {
                    tracing::error!("Failed to save gazetteer: {}", e);
                }
            }
        }

        sanitized
    }

    /// Returns a reference to the inner Mutex-protected Gazetteer.
    ///
    /// WHY: Exposes the gazetteer for monitoring or manual updates in tests and administration.
    ///
    /// CONTRACT: Access is thread-safe via standard Mutex locking.
    pub fn gazetteer(&self) -> &Mutex<Gazetteer> {
        &self.gazetteer
    }
}

impl Default for SynNo {
    /// Creates a default SynNo redactor.
    ///
    /// WHY: Implements standard default trait to simplify setup.
    ///
    /// CONTRACT: Returns standard pre-compiled redaction configuration.
    fn default() -> Self {
        Self::new()
    }
}

/// Statistics about how many redactions were applied per category.
///
/// WHY: Allows the ingestion caller to log metrics, trigger compliance alerts, or trace privacy policy compliance.
///
/// CONTRACT: Tracks counts of all redacted elements. Initialize with default/zero values.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct RedactionStats {
    /// Number of email addresses redacted.
    pub emails: usize,
    /// Number of phone numbers redacted.
    pub phones: usize,
    /// Number of AWS access key IDs redacted.
    pub aws_keys: usize,
    /// Number of JWT tokens redacted.
    pub jwts: usize,
    /// Number of credential/key assignments redacted.
    pub credentials: usize,
    /// Number of SSNs redacted.
    pub ssns: usize,
    /// Number of ZIP codes redacted.
    pub zips: usize,
    /// Number of IP addresses redacted.
    pub ips: usize,
    /// Number of URLs redacted.
    pub urls: usize,
    /// Number of MRNs redacted.
    pub mrns: usize,
    /// Number of dates redacted.
    pub dates: usize,
    /// Number of gazetteer entities redacted.
    pub gazetteer: usize,
}

impl RedactionStats {
    /// Returns the total number of redactions applied.
    ///
    /// WHY: Aggregates total redacted items for simplified auditing.
    ///
    /// CONTRACT: Sum of all category fields.
    pub fn total(&self) -> usize {
        self.emails
            + self.phones
            + self.aws_keys
            + self.jwts
            + self.credentials
            + self.ssns
            + self.zips
            + self.ips
            + self.urls
            + self.mrns
            + self.dates
            + self.gazetteer
    }
}

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

    #[test]
    fn test_syn_no_redaction() {
        let redactor = SynNo::new();
        let raw = "Contact me at alice@example.com or call +1-555-0199. My secret api_key='sk-proj-1234567890ABCDEF'";
        let sanitized = redactor.redact(raw);

        assert!(!sanitized.contains("alice@example.com"));
        assert!(!sanitized.contains("+1-555-0199"));
        assert!(!sanitized.contains("sk-proj-1234567890ABCDEF"));

        assert!(sanitized.contains("[REDACTED_EMAIL]"));
        assert!(sanitized.contains("[REDACTED_PHONE]"));
        assert!(sanitized.contains("api_key: [REDACTED_SECRET]"));
    }

    #[test]
    fn test_aws_key_redaction() {
        let redactor = SynNo::new();
        let raw = "export AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE";
        let sanitized = redactor.redact(raw);
        assert!(!sanitized.contains("AKIAIOSFODNN7EXAMPLE"));
        assert!(sanitized.contains("[REDACTED_AWS_KEY]"));
    }

    #[test]
    fn test_jwt_redaction() {
        let redactor = SynNo::new();
        let raw = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
        let sanitized = redactor.redact(raw);
        assert!(!sanitized.contains("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"));
        assert!(sanitized.contains("[REDACTED_JWT]"));
    }

    #[test]
    fn test_redaction_stats() {
        let redactor = SynNo::new();
        let raw = "Emails: a@b.com, c@d.com. Phones: +1-555-0100, +1-555-0200";
        let (sanitized, stats) = redactor.redact_with_stats(raw);

        assert_eq!(stats.emails, 2);
        assert_eq!(stats.phones, 2);
        assert_eq!(stats.total(), 4);
        assert!(sanitized.contains("[REDACTED_EMAIL]"));
        assert!(sanitized.contains("[REDACTED_PHONE]"));
    }

    #[test]
    fn test_no_false_positives() {
        let redactor = SynNo::new();
        let raw = "The quick brown fox jumps over the lazy dog. Nothing sensitive here.";
        let sanitized = redactor.redact(raw);
        assert_eq!(sanitized, raw);
    }

    #[test]
    fn test_multiple_credential_keys() {
        let redactor = SynNo::new();
        let raw = r#"config:api_key="abcdefghijklmnop",token:"qrstuvwxyz12345678""#;
        let sanitized = redactor.redact(raw);
        assert!(!sanitized.contains("abcdefghijklmnop"));
        assert!(!sanitized.contains("qrstuvwxyz12345678"));
        assert!(sanitized.contains("[REDACTED_SECRET]"));
    }

    /// Verifies HIPAA/PHI de-identification functionality for all newly added regex patterns.
    ///
    /// WHY: Confirms that SSNs, ZIP codes, IP addresses, URLs, MRNs, and Dates are correctly matches and replaced
    /// without leaking raw data, and that stats are counted accurately.
    ///
    /// CONTRACT: All test assertions must pass.
    #[test]
    fn test_hipaa_phi_redaction() {
        let redactor = SynNo::new();
        let raw = "Patient SSN is 000-12-3456, living in ZIP 90210 (or 12345-6789), diagnosed on 2026-05-31. IP: 192.168.1.100. More info at https://medical.org/chart. MRN is mrn: 987654321.";
        let (sanitized, stats) = redactor.redact_with_stats(raw);

        assert!(!sanitized.contains("000-12-3456"));
        assert!(!sanitized.contains("90210"));
        assert!(!sanitized.contains("12345-6789"));
        assert!(!sanitized.contains("192.168.1.100"));
        assert!(!sanitized.contains("https://medical.org/chart"));
        assert!(!sanitized.contains("987654321"));
        assert!(!sanitized.contains("2026-05-31"));

        assert!(sanitized.contains("[REDACTED_SSN]"));
        assert!(sanitized.contains("[REDACTED_ZIP]"));
        assert!(sanitized.contains("[REDACTED_IP]"));
        assert!(sanitized.contains("[REDACTED_URL]"));
        assert!(sanitized.contains("[REDACTED_MRN]"));
        assert!(sanitized.contains("[REDACTED_DATE]"));

        assert_eq!(stats.ssns, 1);
        assert_eq!(stats.zips, 2);
        assert_eq!(stats.ips, 1);
        assert_eq!(stats.urls, 1);
        assert_eq!(stats.mrns, 1);
        assert_eq!(stats.dates, 1);
        assert_eq!(stats.total(), 7);
    }
}
