//! Personal-data and live-data review heuristics for distilled suites.
//!
//! Why this exists: a recording is real traffic, so the args and the
//! exact-value assertions a distilled suite lifts from it can carry a
//! customer email, a bearer token the [`crate::redaction`] shapes missed,
//! or a production URL. Distillation must never rewrite recorded values
//! (the suite has to replay), so this module only *flags*: it classifies
//! suspicious strings and the caller marks the affected test with a
//! `# review:` comment for a human to judge.

use std::sync::OnceLock;

use regex::Regex;

/// Why a recorded value was flagged for human review.
///
/// Carried on every [`ReviewFlag`] so the CLI can print a per-category
/// count and the operator knows what kind of data to look for.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[non_exhaustive]
pub enum ReviewCategory {
    /// The value contains an email address.
    Email,
    /// The value contains a bearer-like token (a long base64 or hex run
    /// that survived the shape-based redaction pass).
    Token,
    /// The value contains an absolute URL whose host is not a
    /// documentation or loopback host, so it may point at a live system.
    Url,
}

impl ReviewCategory {
    /// Short lowercase label used in stderr summaries and flag listings.
    pub fn label(self) -> &'static str {
        match self {
            ReviewCategory::Email => "email",
            ReviewCategory::Token => "token",
            ReviewCategory::Url => "url",
        }
    }
}

/// One suspicious value found in a distilled test, named by location so a
/// reviewer can jump straight to it instead of re-reading the whole suite.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReviewFlag {
    /// Name of the distilled test the value lands in.
    pub test: String,
    /// Where inside the test the value sits: `args.<path>` for recorded
    /// arguments, or the assertion target (executor path syntax) for an
    /// exact-value assertion.
    pub path: String,
    /// What kind of data the value looks like.
    pub category: ReviewCategory,
}

/// Classify one string value, returning the first matching category in
/// the order email, token, URL. One category per value keeps the flag
/// list readable; a value that is both is still reviewed either way.
pub(crate) fn categorize(value: &str) -> Option<ReviewCategory> {
    if email_re().is_match(value) {
        return Some(ReviewCategory::Email);
    }
    if looks_like_token(value) {
        return Some(ReviewCategory::Token);
    }
    if has_non_example_url(value) {
        return Some(ReviewCategory::Url);
    }
    None
}

/// Simple email shape: local part, `@`, dotted domain with an alphabetic
/// TLD. Deliberately loose; this is a review prompt, not a validator.
fn email_re() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        // The pattern is a fixed literal, so compilation cannot fail.
        Regex::new(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}").unwrap_or_else(|e| {
            unreachable!("static email regex must compile: {e}");
        })
    })
}

/// Bearer-like detection: an explicit `Bearer <blob>` prefix, or a long
/// (30+ char) base64/hex-alphabet run mixing letters and digits. The
/// shape-based [`crate::redaction`] already scrubs the obvious prefixes
/// and mixed-case blobs; this catches what survives (lowercase tokens,
/// `Bearer`-prefixed short secrets) so a human takes a second look.
fn looks_like_token(value: &str) -> bool {
    static BEARER: OnceLock<Regex> = OnceLock::new();
    static BLOB: OnceLock<Regex> = OnceLock::new();
    let bearer = BEARER.get_or_init(|| {
        Regex::new(r"(?i)\bbearer\s+[A-Za-z0-9._+/=-]{12,}").unwrap_or_else(|e| {
            unreachable!("static bearer regex must compile: {e}");
        })
    });
    if bearer.is_match(value) {
        return true;
    }
    let blob = BLOB.get_or_init(|| {
        Regex::new(r"[A-Za-z0-9+/_-]{30,}={0,2}").unwrap_or_else(|e| {
            unreachable!("static blob regex must compile: {e}");
        })
    });
    blob.find_iter(value).any(|m| {
        let s = m.as_str();
        s.chars().any(|c| c.is_ascii_alphabetic()) && s.chars().any(|c| c.is_ascii_digit())
    })
}

/// True when the value carries an absolute `http(s)://` URL whose host is
/// neither loopback nor a documentation/reserved name. Such a URL likely
/// points at a live system the suite should not advertise.
fn has_non_example_url(value: &str) -> bool {
    static URL: OnceLock<Regex> = OnceLock::new();
    let url = URL.get_or_init(|| {
        Regex::new(r"(?i)\bhttps?://([A-Za-z0-9.-]+)").unwrap_or_else(|e| {
            unreachable!("static url regex must compile: {e}");
        })
    });
    url.captures_iter(value).any(|cap| {
        cap.get(1)
            .map(|host| !is_safe_host(host.as_str()))
            .unwrap_or(false)
    })
}

/// Hosts that never indicate live data: loopback names and the
/// RFC 2606 / RFC 6761 documentation and testing domains.
fn is_safe_host(host: &str) -> bool {
    let host = host.to_ascii_lowercase();
    if matches!(host.as_str(), "localhost" | "127.0.0.1" | "0.0.0.0") {
        return true;
    }
    for safe in ["example.com", "example.org", "example.net"] {
        if host == safe || host.ends_with(&format!(".{safe}")) {
            return true;
        }
    }
    [".example", ".test", ".invalid", ".localhost"]
        .iter()
        .any(|suffix| host.ends_with(*suffix))
}

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

    #[test]
    fn emails_are_flagged_wherever_they_appear_in_a_string() {
        assert_eq!(
            categorize("contact rick.crawford@gmail.com today"),
            Some(ReviewCategory::Email)
        );
        assert_eq!(categorize("user@example.com"), Some(ReviewCategory::Email));
        assert_eq!(categorize("not-an-email@nowhere"), None);
    }

    #[test]
    fn bearer_prefixed_and_long_lowercase_tokens_are_flagged() {
        assert_eq!(
            categorize("Bearer abc123def456ghi7"),
            Some(ReviewCategory::Token)
        );
        // 40 lowercase alphanumerics: the redactor's mixed-case heuristic
        // misses it, so the review pass must catch it.
        assert_eq!(
            categorize("a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"),
            Some(ReviewCategory::Token)
        );
        // A redaction placeholder must not re-flag.
        assert_eq!(categorize("[REDACTED:base64-blob]"), None);
        assert_eq!(categorize("just ordinary words"), None);
    }

    #[test]
    fn urls_with_live_hosts_are_flagged_and_documentation_hosts_are_not() {
        assert_eq!(
            categorize("https://api.acme-corp.io/v2/users"),
            Some(ReviewCategory::Url)
        );
        assert_eq!(categorize("https://example.com/docs"), None);
        assert_eq!(categorize("http://sub.example.org/x"), None);
        assert_eq!(categorize("http://localhost:9000/mcp"), None);
        assert_eq!(categorize("http://127.0.0.1:8080"), None);
        assert_eq!(categorize("https://server.test/mcp"), None);
        assert_eq!(categorize("a relative /path stays quiet"), None);
    }

    #[test]
    fn email_wins_over_url_when_both_shapes_appear() {
        assert_eq!(
            categorize("mail bob@corp.io via https://corp.io"),
            Some(ReviewCategory::Email)
        );
    }
}
