//! Central redaction policy for secret-shaped strings.
//!
//! Why this module exists: redaction logic used to live in eight different
//! call sites (reporter, verbose log, cassette dump, doctor report, error
//! formatter, and more). Each implementation drifted, so a token that one
//! surface redacted slipped through another. This crate is the single
//! source of truth. Every reporter, log line, and error message that may
//! carry user data calls into [`Redactor`].
//!
//! The redactor is intentionally narrow: it detects shapes (token prefixes,
//! hex blobs, high-entropy base64, secret-named JSON keys) and replaces
//! them with `[REDACTED:<hint>]` so downstream consumers can still tell two
//! distinct secrets apart by category.

use std::borrow::Cow;
use std::collections::BTreeSet;
use std::sync::OnceLock;

use regex::Regex;
use serde_json::Value;
use thiserror::Error;

/// Categories of strings that the redactor recognises as secret.
///
/// Documented here so contributors do not have to re-derive the list from
/// regex bodies. See `docs/security/redaction.md` for the user-facing
/// version.
const _SECRET_CATEGORIES: &[&str] = &[
    "token-prefix (sk-, pk-, ghp_, gho_, ghs_, xoxb-, xoxp-, eyJ JWT)",
    "hex-blob (32+ hex chars)",
    "high-entropy base64-ish blob (32+ chars, mixed case + digits)",
    "secret-named JSON key (password, token, secret, api_key, ...)",
    "known secret env-var value (registered via with_secret_env)",
    "HTTP header named Authorization, Cookie, Proxy-Authorization, Set-Cookie",
    "HTTP header matching *-Token / *-Key / *-Secret / *-Auth*",
];

/// Redacts secret-shaped strings. Categories are documented.
///
/// Construct one redactor per CLI invocation, register any
/// `bearer_token_env` references via [`Redactor::with_secret_env`], then
/// pass it to every surface that prints user data. The struct is `Send +
/// Sync` so it can be shared across reporter threads.
#[derive(Debug, Default, Clone)]
pub struct Redactor {
    /// Names of OS env vars whose values are known secrets (from
    /// `bearer_token_env` references). Always redacted when seen.
    secret_env_names: BTreeSet<String>,
    /// Resolved secret values registered via
    /// [`Redactor::with_secret_value`]. Stored so the loader
    /// can hand the redactor every value produced by `${secret:NAME}`
    /// interpolation and downstream reporters scrub them without
    /// having to know the source variable name.
    secret_values: BTreeSet<String>,
}

impl Redactor {
    /// Construct an empty redactor that detects only shape-based secrets.
    pub fn new() -> Self {
        Self::default()
    }

    /// Register an env-var name whose resolved value should always be
    /// treated as a secret. Returns `self` for fluent chaining.
    pub fn with_secret_env(mut self, name: impl Into<String>) -> Self {
        self.secret_env_names.insert(name.into());
        self
    }

    /// Register a resolved secret value. Used by the config
    /// loader when `${secret:NAME}` interpolation produces a value the
    /// downstream reporter must scrub even when the value does not
    /// match the shape-based heuristics. Empty values are ignored so a
    /// `${secret:UNSET:-}` does not turn every empty string in the
    /// report into `[REDACTED:secret]`.
    pub fn with_secret_value(mut self, value: impl Into<String>) -> Self {
        let v = value.into();
        if !v.is_empty() {
            self.secret_values.insert(v);
        }
        self
    }

    /// Bulk register resolved secret values. Convenience wrapper used
    /// at runner-init time when a [`Vec`] of values comes from the
    /// loader.
    pub fn with_secret_values<I, S>(mut self, values: I) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        for v in values {
            let s = v.into();
            if !s.is_empty() {
                self.secret_values.insert(s);
            }
        }
        self
    }

    /// Redact a single value. Returns the redacted form when the value
    /// matches a known-secret pattern, or the value unchanged otherwise.
    pub fn redact_value<'a>(&self, value: &'a str) -> Cow<'a, str> {
        if value.is_empty() {
            return Cow::Borrowed(value);
        }
        if self.secret_values.contains(value) {
            return Cow::Owned(format_redaction("secret", value.len()));
        }
        // Registered secrets are known plaintext (a resolved `scheme://`
        // reference or `${secret:NAME}`), so scrub them wherever they appear,
        // not only when they are the whole field. A reference resolved into a
        // free-text test name, failure message, or diff would otherwise leak.
        if let Some(scrubbed) = self.scrub_secret_substrings(value) {
            return Cow::Owned(scrubbed);
        }
        if let Some(category) = classify_value(value) {
            return Cow::Owned(format_redaction(&category, value.len()));
        }
        Cow::Borrowed(value)
    }

    /// Replace every occurrence of a registered secret value inside `s` with
    /// the `[REDACTED:secret]` marker. Returns `Some` only when at least one
    /// secret was found so callers keep their borrowed fast path otherwise.
    /// Shape-detected secrets stay whole-value; only the known plaintext
    /// registered via [`Redactor::with_secret_value`] is scrubbed as a
    /// substring.
    fn scrub_secret_substrings(&self, s: &str) -> Option<String> {
        if self.secret_values.is_empty() {
            return None;
        }
        let mut current = s.to_string();
        let mut hit = false;
        for secret in &self.secret_values {
            if current.contains(secret.as_str()) {
                current =
                    current.replace(secret.as_str(), &format_redaction("secret", secret.len()));
                hit = true;
            }
        }
        if hit {
            Some(current)
        } else {
            None
        }
    }

    /// Redact an HTTP header (name, value pair). Some headers always
    /// redact (Authorization, Cookie, *-Token, *-Key, *-Secret, *-Auth*).
    pub fn redact_header<'a>(&self, name: &str, value: &'a str) -> Cow<'a, str> {
        if header_always_redacts(name) {
            return Cow::Owned("[REDACTED:header]".to_string());
        }
        self.redact_value(value)
    }

    /// Walk a [`serde_json::Value`] recursively and replace any string
    /// that looks like a secret with the redacted form. Returns a new
    /// Value.
    pub fn redact_json(&self, value: &Value) -> Value {
        self.redact_json_inner(value, None)
    }

    fn redact_json_inner(&self, value: &Value, parent_key: Option<&str>) -> Value {
        match value {
            Value::String(s) => self.redact_string_with_key(s, parent_key),
            Value::Array(items) => Value::Array(
                items
                    .iter()
                    .map(|item| self.redact_json_inner(item, None))
                    .collect(),
            ),
            Value::Object(map) => {
                let mut out = serde_json::Map::with_capacity(map.len());
                for (k, v) in map {
                    out.insert(k.clone(), self.redact_json_inner(v, Some(k.as_str())));
                }
                Value::Object(out)
            }
            // Numbers, bools, and nulls cannot encode a secret on their own.
            other => other.clone(),
        }
    }

    fn redact_string_with_key(&self, s: &str, parent_key: Option<&str>) -> Value {
        // 1. Env-name match wins outright: if the value equals any
        //    registered secret env value placeholder, redact aggressively.
        if self.secret_env_names.contains(s) {
            // The struct stores env *names*, but callers also register the
            // resolved values via the same set (see ). Both paths
            // funnel here.
            return Value::String(format_redaction("env", s.len()));
        }
        // 2. Resolved-secret match: values produced by
        //    `${secret:NAME}` interpolation are scrubbed even when the
        //    shape heuristics would have let them through.
        if !s.is_empty() && self.secret_values.contains(s) {
            return Value::String(format_redaction("secret", s.len()));
        }
        // 2b. Registered-secret substring: scrub a known secret even when it
        //     is embedded inside a larger string leaf, not only when it is the
        //     whole value.
        if let Some(scrubbed) = self.scrub_secret_substrings(s) {
            return Value::String(scrubbed);
        }
        // 3. Parent-key hint: redact non-empty value under a secret-named key.
        if let Some(key) = parent_key {
            if key_signals_secret(key) && !s.is_empty() {
                return Value::String(format!("[REDACTED:hint={key}]"));
            }
        }
        // 4. Shape-based detection.
        if let Some(category) = classify_value(s) {
            return Value::String(format_redaction(&category, s.len()));
        }
        Value::String(s.to_string())
    }
}

/// Structured error raised when a smoke test detects a leaked secret-shaped
/// value on a CLI surface.
///
/// Used by the verification test suite at
/// `crates/mcptest/tests/redaction_audit.rs`. Production code does not
/// raise this; it is purely a test affordance.
#[derive(Debug, Error)]
pub enum SecretAuditError {
    /// A secret-shaped value appeared on the named surface.
    #[error("surface `{surface}` leaked a secret-shaped value: {sample}")]
    Leak {
        /// Which CLI surface leaked the value (for example "doctor stdout").
        surface: String,
        /// A short excerpt of the leak so the failure is debuggable.
        sample: String,
    },
}

/// Decide which category a value falls into, if any.
fn classify_value(value: &str) -> Option<String> {
    if has_token_prefix(value) {
        return Some("token".to_string());
    }
    if is_hex_blob(value) {
        return Some(format!("length={}", value.len()));
    }
    if is_high_entropy_blob(value) {
        return Some(format!("length={}", value.len()));
    }
    None
}

fn has_token_prefix(value: &str) -> bool {
    const PREFIXES: &[&str] = &[
        "sk-", "pk-", "ghp_", "gho_", "ghs_", "xoxb-", "xoxp-", "eyJ",
    ];
    PREFIXES.iter().any(|p| value.starts_with(p))
}

fn is_hex_blob(value: &str) -> bool {
    if value.len() < 32 {
        return false;
    }
    value.chars().all(|c| c.is_ascii_hexdigit())
}

fn is_high_entropy_blob(value: &str) -> bool {
    if value.len() < 32 {
        return false;
    }
    let mut digits = 0usize;
    let mut uppers = 0usize;
    let mut lowers = 0usize;
    let mut other = 0usize;
    for c in value.chars() {
        if c.is_ascii_digit() {
            digits += 1;
        } else if c.is_ascii_uppercase() {
            uppers += 1;
        } else if c.is_ascii_lowercase() {
            lowers += 1;
        } else if c == '+' || c == '/' || c == '=' || c == '-' || c == '_' {
            // base64 / base64url filler. Allowed.
        } else {
            other += 1;
        }
    }
    if other > 0 {
        return false;
    }
    digits >= 2 && uppers >= 2 && lowers >= 2
}

fn header_always_redacts(name: &str) -> bool {
    let lower = name.to_ascii_lowercase();
    matches!(
        lower.as_str(),
        "authorization" | "cookie" | "proxy-authorization" | "set-cookie"
    ) || generic_secret_header().is_match(&lower)
}

fn key_signals_secret(key: &str) -> bool {
    secret_name_regex().is_match(key)
}

fn format_redaction(category: &str, _len: usize) -> String {
    format!("[REDACTED:{category}]")
}

fn secret_name_regex() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(
            r"(?i)(password|token|secret|api[-_]?key|client[-_]?secret|access[-_]?token|refresh[-_]?token|^key$|_key$|-key$)",
        )
        .expect("secret name regex compiles")
    })
}

fn generic_secret_header() -> &'static Regex {
    static RE: OnceLock<Regex> = OnceLock::new();
    RE.get_or_init(|| {
        Regex::new(r"(?i)-(token|key|secret|auth[a-z0-9-]*)$").expect("header regex compiles")
    })
}

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

    #[test]
    fn detects_each_known_token_prefix() {
        let r = Redactor::new();
        for sample in [
            "sk-abcdef1234567890",
            "pk-abcdef1234567890",
            "ghp_abcdef1234567890",
            "gho_abcdef1234567890",
            "ghs_abcdef1234567890",
            "xoxb-1234567890-abc",
            "xoxp-1234567890-abc",
            "eyJhbGciOiJIUzI1NiJ9.payload.sig",
        ] {
            let out = r.redact_value(sample);
            assert_eq!(out, "[REDACTED:token]", "did not redact {sample}");
        }
    }

    #[test]
    fn detects_hex_blob_at_32_chars() {
        let r = Redactor::new();
        let hex32 = "a".repeat(32);
        assert_eq!(r.redact_value(&hex32), "[REDACTED:length=32]");
    }

    #[test]
    fn ignores_hex_blob_at_31_chars() {
        let r = Redactor::new();
        let hex31 = "a".repeat(31);
        assert_eq!(r.redact_value(&hex31), hex31);
    }

    #[test]
    fn detects_high_entropy_base64_blob() {
        let r = Redactor::new();
        // 32 chars, 2+ digits, 2+ uppers, 2+ lowers, mixed.
        let sample = "AbcdefghijklmnopQRstuvWX12yz34AB";
        assert_eq!(sample.len(), 32);
        assert!(matches!(r.redact_value(sample), Cow::Owned(_)));
    }

    #[test]
    fn lets_low_entropy_short_string_through() {
        let r = Redactor::new();
        assert_eq!(r.redact_value("hello world"), "hello world");
        assert_eq!(r.redact_value("user@example.com"), "user@example.com");
    }

    #[test]
    fn header_authorization_always_redacts() {
        let r = Redactor::new();
        let out = r.redact_header("Authorization", "Bearer plain-value-x");
        assert_eq!(out, "[REDACTED:header]");
    }

    #[test]
    fn header_cookie_always_redacts() {
        let r = Redactor::new();
        assert_eq!(r.redact_header("Cookie", "sid=1"), "[REDACTED:header]");
    }

    #[test]
    fn header_proxy_authorization_always_redacts() {
        let r = Redactor::new();
        assert_eq!(
            r.redact_header("Proxy-Authorization", "Basic xyz"),
            "[REDACTED:header]"
        );
    }

    #[test]
    fn header_set_cookie_always_redacts() {
        let r = Redactor::new();
        assert_eq!(
            r.redact_header("Set-Cookie", "sid=1; Path=/"),
            "[REDACTED:header]"
        );
    }

    #[test]
    fn header_generic_token_suffix_redacts() {
        let r = Redactor::new();
        assert_eq!(r.redact_header("X-Api-Token", "v"), "[REDACTED:header]");
        assert_eq!(r.redact_header("X-Service-Key", "v"), "[REDACTED:header]");
        assert_eq!(r.redact_header("X-Custom-Secret", "v"), "[REDACTED:header]");
        assert_eq!(r.redact_header("X-Auth", "v"), "[REDACTED:header]");
        assert_eq!(
            r.redact_header("X-Authentication", "v"),
            "[REDACTED:header]"
        );
    }

    #[test]
    fn header_normal_passes_through() {
        let r = Redactor::new();
        assert_eq!(
            r.redact_header("Content-Type", "application/json"),
            "application/json"
        );
        assert_eq!(r.redact_header("User-Agent", "mcptest/0.1"), "mcptest/0.1");
    }

    #[test]
    fn json_walk_redacts_nested_secret_named_field() {
        let r = Redactor::new();
        let input = json!({
            "outer": {
                "password": "anything-here",
                "harmless": "plain text"
            }
        });
        let out = r.redact_json(&input);
        assert_eq!(out["outer"]["password"], "[REDACTED:hint=password]");
        assert_eq!(out["outer"]["harmless"], "plain text");
    }

    #[test]
    fn json_walk_redacts_array_of_strings_with_token_shape() {
        let r = Redactor::new();
        let input = json!(["plain", "ghp_abcdefghijklmnop"]);
        let out = r.redact_json(&input);
        assert_eq!(out[0], "plain");
        assert_eq!(out[1], "[REDACTED:token]");
    }

    #[test]
    fn json_walk_redacts_top_level_string_with_token_shape() {
        let r = Redactor::new();
        let out = r.redact_json(&json!("ghp_abcdefghijklmnop"));
        assert_eq!(out, "[REDACTED:token]");
    }

    #[test]
    fn json_walk_leaves_numbers_and_bools_alone() {
        let r = Redactor::new();
        let input = json!({"count": 42, "flag": true, "value": null});
        let out = r.redact_json(&input);
        assert_eq!(out, input);
    }

    #[test]
    fn json_walk_handles_empty_string_under_secret_key() {
        let r = Redactor::new();
        // Empty values should not be redacted (nothing to hide).
        let input = json!({"password": ""});
        let out = r.redact_json(&input);
        assert_eq!(out["password"], "");
    }

    #[test]
    fn with_secret_env_redacts_registered_value() {
        let r = Redactor::new().with_secret_env("custom-known-value");
        let input = json!({"any": "custom-known-value"});
        let out = r.redact_json(&input);
        // The exact category is "env"; only the shape of the result matters.
        let s = out["any"].as_str().expect("string");
        assert!(s.starts_with("[REDACTED:env"), "got {s}");
    }

    #[test]
    fn secret_audit_error_renders_surface_and_sample() {
        let err = SecretAuditError::Leak {
            surface: "doctor stdout".to_string(),
            sample: "ghp_abc".to_string(),
        };
        let msg = err.to_string();
        assert!(msg.contains("doctor stdout"));
        assert!(msg.contains("ghp_abc"));
    }

    #[test]
    fn key_signals_secret_matches_common_names() {
        for name in [
            "password",
            "Password",
            "TOKEN",
            "api_key",
            "api-key",
            "apiKey",
            "client_secret",
            "access_token",
            "refresh-token",
        ] {
            assert!(key_signals_secret(name), "expected {name} to match");
        }
    }

    #[test]
    fn key_signals_secret_ignores_unrelated_names() {
        for name in ["username", "email", "id", "count", "timeout"] {
            assert!(!key_signals_secret(name), "expected {name} not to match");
        }
    }

    #[test]
    fn redact_value_borrows_when_no_match() {
        let r = Redactor::new();
        let s = "plain";
        match r.redact_value(s) {
            Cow::Borrowed(b) => assert_eq!(b, s),
            Cow::Owned(_) => panic!("should borrow when value is not a secret"),
        }
    }

    #[test]
    fn registered_secret_is_scrubbed_when_embedded_in_free_text() {
        // A resolved reference (a shape-inert word, no token prefix) embedded
        // in a larger string must still be scrubbed, not just when it is the
        // whole field.
        let r = Redactor::new().with_secret_value("topsecretplainvalue");
        let out = r.redact_value("server rejected topsecretplainvalue at hop 2");
        assert!(
            !out.contains("topsecretplainvalue"),
            "embedded secret leaked: {out}"
        );
        assert!(out.contains("[REDACTED:secret]"), "no marker: {out}");
    }

    #[test]
    fn registered_secret_is_scrubbed_inside_a_json_string_leaf() {
        let r = Redactor::new().with_secret_value("topsecretplainvalue");
        let payload = serde_json::json!({ "note": "used topsecretplainvalue here" });
        let scrubbed = r.redact_json(&payload);
        let rendered = serde_json::to_string(&scrubbed).expect("serializes");
        assert!(
            !rendered.contains("topsecretplainvalue"),
            "embedded secret leaked: {rendered}"
        );
        assert!(
            rendered.contains("[REDACTED:secret]"),
            "no marker: {rendered}"
        );
    }
}
