//! The static config-scan rules (CFG-NNN).
//!
//! Each rule is a pure function over one server object (the raw JSON value, so
//! client-specific keys such as `autoApprove` stay visible) and returns zero or
//! more findings. Detection reuses [`crate::redaction`] for secret shapes so the
//! scanner and the reporter agree on what counts as a secret.

use std::borrow::Cow;

use serde_json::Value;

use crate::redaction::Redactor;
use crate::security::SecuritySeverity;

use super::model::ConfigFinding;

/// Run every config rule against one server object and return its findings.
///
/// Findings come back in rule-id order so reporter output is deterministic.
pub fn scan_server(name: &str, server: &Value) -> Vec<ConfigFinding> {
    let mut out = Vec::new();
    out.extend(cfg001_hardcoded_secrets(name, server));
    out.extend(cfg002_broad_filesystem(name, server));
    out.extend(cfg003_missing_required_env(name, server));
    out.extend(cfg004_auto_approve(name, server));
    out
}

/// CFG-001: an `env` value that is itself a secret-shaped literal.
fn cfg001_hardcoded_secrets(name: &str, server: &Value) -> Vec<ConfigFinding> {
    let Some(env) = server.get("env").and_then(Value::as_object) else {
        return Vec::new();
    };
    let redactor = Redactor::new();
    let mut out = Vec::new();
    for (key, value) in env {
        let Some(text) = value.as_str() else { continue };
        // A fresh redactor has no registered secrets, so an Owned result means
        // the value matched a shape heuristic (token prefix, hex, or entropy):
        // a literal credential, not a `${VAR}` reference.
        let Cow::Owned(marker) = redactor.redact_value(text) else {
            continue;
        };
        out.push(ConfigFinding {
            rule_id: "CFG-001".into(),
            name: "hardcoded-secret".into(),
            severity: SecuritySeverity::Critical,
            server: name.to_string(),
            message: format!("env var `{key}` holds a hardcoded secret value in the config file"),
            evidence: format!("{key}={marker}"),
            remediation:
                "remove the literal value; reference a secret manager or an env var like ${...} instead"
                    .into(),
        });
    }
    out
}

/// CFG-002: a `command`/`args` token that grants broad filesystem access.
fn cfg002_broad_filesystem(name: &str, server: &Value) -> Vec<ConfigFinding> {
    let mut tokens: Vec<&str> = Vec::new();
    if let Some(cmd) = server.get("command").and_then(Value::as_str) {
        tokens.push(cmd);
    }
    if let Some(args) = server.get("args").and_then(Value::as_array) {
        tokens.extend(args.iter().filter_map(Value::as_str));
    }
    let mut out = Vec::new();
    for token in tokens {
        let Some(reason) = broad_fs_reason(token) else {
            continue;
        };
        out.push(ConfigFinding {
            rule_id: "CFG-002".into(),
            name: "broad-filesystem-access".into(),
            severity: SecuritySeverity::High,
            server: name.to_string(),
            message: format!(
                "server `{name}` is launched with {reason}, granting broad filesystem access"
            ),
            evidence: token.to_string(),
            remediation:
                "scope the server to a specific project directory, not a system or home root".into(),
        });
    }
    out
}

/// Classify a launch token as a broad filesystem grant, returning the reason.
fn broad_fs_reason(token: &str) -> Option<String> {
    if token == "/" {
        return Some("the filesystem root `/`".to_string());
    }
    if token == "~" || token.starts_with("~/") {
        return Some("the home directory `~`".to_string());
    }
    const SENSITIVE: &[&str] = &[
        "/etc", "/var", "/root", "/home", "/usr", "/sys", "/proc", "/Users", "/Library",
    ];
    for prefix in SENSITIVE {
        if token == *prefix || token.starts_with(&format!("{prefix}/")) {
            return Some(format!("the sensitive path `{prefix}`"));
        }
    }
    if is_windows_broad(token) {
        return Some(format!("the Windows system path `{token}`"));
    }
    None
}

/// True for a Windows drive root (`C:\`) or a system path under one.
fn is_windows_broad(token: &str) -> bool {
    let bytes = token.as_bytes();
    if bytes.len() < 2 || !bytes[0].is_ascii_alphabetic() || bytes[1] != b':' {
        return false;
    }
    let rest = token[2..].replace('\\', "/").to_ascii_lowercase();
    rest.is_empty()
        || rest == "/"
        || rest.starts_with("/users")
        || rest.starts_with("/windows")
        || rest.starts_with("/program files")
}

/// Known credentialed servers keyed by a command/arg substring, with the env
/// var names that satisfy the credential requirement.
const AUTH_SERVERS: &[(&str, &[&str])] = &[
    ("github", &["GITHUB_TOKEN", "GITHUB_PERSONAL_ACCESS_TOKEN"]),
    ("gitlab", &["GITLAB_TOKEN", "GITLAB_PERSONAL_ACCESS_TOKEN"]),
    (
        "postgres",
        &["DATABASE_URL", "POSTGRES_CONNECTION_STRING", "PGDATABASE"],
    ),
    ("stripe", &["STRIPE_API_KEY", "STRIPE_SECRET_KEY"]),
    ("slack", &["SLACK_BOT_TOKEN", "SLACK_TOKEN"]),
    ("brave", &["BRAVE_API_KEY"]),
    ("notion", &["NOTION_API_KEY", "NOTION_TOKEN"]),
    ("sentry", &["SENTRY_AUTH_TOKEN"]),
];

/// CFG-003: a known auth-needing server with no credential env var set.
fn cfg003_missing_required_env(name: &str, server: &Value) -> Vec<ConfigFinding> {
    let haystack = launch_haystack(server);
    let Some((keyword, required)) = AUTH_SERVERS.iter().find(|(kw, _)| haystack.contains(kw))
    else {
        return Vec::new();
    };
    let env = server.get("env").and_then(Value::as_object);
    let satisfied = required.iter().any(|var| {
        env.and_then(|e| e.get(*var))
            .and_then(Value::as_str)
            .is_some_and(|v| !v.trim().is_empty())
    });
    if satisfied {
        return Vec::new();
    }
    vec![ConfigFinding {
        rule_id: "CFG-003".into(),
        name: "missing-required-env".into(),
        severity: SecuritySeverity::Medium,
        server: name.to_string(),
        message: format!(
            "server `{name}` looks like a {keyword} server but sets no credential env var (expected one of: {})",
            required.join(", ")
        ),
        evidence: format!("env does not set {}", required.join(" or ")),
        remediation:
            "set the server's credential via an env var reference so authentication is configured"
                .into(),
    }]
}

/// Lowercased command + args, for keyword matching.
fn launch_haystack(server: &Value) -> String {
    let mut s = String::new();
    if let Some(cmd) = server.get("command").and_then(Value::as_str) {
        s.push_str(&cmd.to_ascii_lowercase());
        s.push(' ');
    }
    if let Some(args) = server.get("args").and_then(Value::as_array) {
        for a in args.iter().filter_map(Value::as_str) {
            s.push_str(&a.to_ascii_lowercase());
            s.push(' ');
        }
    }
    s
}

/// CFG-004: tool calls auto-approved with no human gate.
fn cfg004_auto_approve(name: &str, server: &Value) -> Vec<ConfigFinding> {
    // Clients spell the "skip the human" setting differently.
    for key in ["autoApprove", "alwaysAllow", "auto_approve", "always_allow"] {
        let Some(value) = server.get(key) else {
            continue;
        };
        let entries: Vec<&str> = match value {
            Value::Array(items) => items.iter().filter_map(Value::as_str).collect(),
            Value::Bool(true) => vec!["*"],
            _ => continue,
        };
        if entries.is_empty() {
            continue;
        }
        let wildcard = entries.contains(&"*");
        let severity = if wildcard {
            SecuritySeverity::Medium
        } else {
            SecuritySeverity::Low
        };
        return vec![ConfigFinding {
            rule_id: "CFG-004".into(),
            name: "auto-approved-tools".into(),
            severity,
            server: name.to_string(),
            message: format!(
                "server `{name}` auto-approves tool calls via `{key}`, removing the human approval gate"
            ),
            evidence: format!("{key} = {}", entries.join(", ")),
            remediation:
                "require human approval for mutating or destructive tools instead of auto-approving"
                    .into(),
        }];
    }
    Vec::new()
}

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

    fn ids(findings: &[ConfigFinding]) -> Vec<&str> {
        findings.iter().map(|f| f.rule_id.as_str()).collect()
    }

    #[test]
    fn cfg001_flags_a_hardcoded_token_in_env() {
        let server = json!({
            "command": "node",
            "env": { "GITHUB_TOKEN": "ghp_abcdefghijklmnopqrstuvwxyz0123456789" }
        });
        let findings = cfg001_hardcoded_secrets("gh", &server);
        assert_eq!(ids(&findings), vec!["CFG-001"]);
        assert_eq!(findings[0].severity, SecuritySeverity::Critical);
        // The literal secret must never appear in the output.
        assert!(!findings[0]
            .evidence
            .contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789"));
    }

    #[test]
    fn cfg001_ignores_an_env_var_reference() {
        let server = json!({
            "command": "node",
            "env": { "GITHUB_TOKEN": "${GITHUB_TOKEN}", "OTHER": "$BRAVE_API_KEY" }
        });
        assert!(cfg001_hardcoded_secrets("gh", &server).is_empty());
    }

    #[test]
    fn cfg002_flags_filesystem_root_arg() {
        let server = json!({
            "command": "npx",
            "args": ["@modelcontextprotocol/server-filesystem", "/"]
        });
        let findings = cfg002_broad_filesystem("fs", &server);
        assert_eq!(ids(&findings), vec!["CFG-002"]);
        assert_eq!(findings[0].severity, SecuritySeverity::High);
    }

    #[test]
    fn cfg002_flags_home_and_etc_paths() {
        let server = json!({ "command": "x", "args": ["~/", "/etc/ssh"] });
        let findings = cfg002_broad_filesystem("fs", &server);
        assert_eq!(findings.len(), 2);
    }

    #[test]
    fn cfg002_flags_windows_drive_root() {
        let server = json!({ "command": "srv.exe", "args": ["C:\\"] });
        assert_eq!(cfg002_broad_filesystem("fs", &server).len(), 1);
    }

    #[test]
    fn cfg002_allows_a_scoped_project_path() {
        let server = json!({ "command": "x", "args": ["./data", "/opt/app/workspace"] });
        assert!(cfg002_broad_filesystem("fs", &server).is_empty());
    }

    #[test]
    fn cfg003_flags_github_server_without_credentials() {
        let server = json!({
            "command": "npx",
            "args": ["-y", "@modelcontextprotocol/server-github"],
            "env": {}
        });
        let findings = cfg003_missing_required_env("gh", &server);
        assert_eq!(ids(&findings), vec!["CFG-003"]);
        assert_eq!(findings[0].severity, SecuritySeverity::Medium);
    }

    #[test]
    fn cfg003_satisfied_when_a_credential_is_set() {
        let server = json!({
            "command": "npx",
            "args": ["@modelcontextprotocol/server-github"],
            "env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GH}" }
        });
        assert!(cfg003_missing_required_env("gh", &server).is_empty());
    }

    #[test]
    fn cfg003_treats_empty_credential_as_missing() {
        let server = json!({
            "command": "npx",
            "args": ["@modelcontextprotocol/server-github"],
            "env": { "GITHUB_TOKEN": "" }
        });
        assert_eq!(cfg003_missing_required_env("gh", &server).len(), 1);
    }

    #[test]
    fn cfg003_ignores_an_unknown_server() {
        let server = json!({ "command": "node", "args": ["my-server.js"], "env": {} });
        assert!(cfg003_missing_required_env("x", &server).is_empty());
    }

    #[test]
    fn cfg004_flags_wildcard_auto_approve_as_medium() {
        let server = json!({ "command": "x", "autoApprove": ["*"] });
        let findings = cfg004_auto_approve("x", &server);
        assert_eq!(ids(&findings), vec!["CFG-004"]);
        assert_eq!(findings[0].severity, SecuritySeverity::Medium);
    }

    #[test]
    fn cfg004_flags_specific_auto_approve_as_low() {
        let server = json!({ "command": "x", "alwaysAllow": ["delete_file"] });
        let findings = cfg004_auto_approve("x", &server);
        assert_eq!(findings.len(), 1);
        assert_eq!(findings[0].severity, SecuritySeverity::Low);
    }

    #[test]
    fn cfg004_quiet_when_no_auto_approve() {
        let server = json!({ "command": "x", "args": [] });
        assert!(cfg004_auto_approve("x", &server).is_empty());
    }

    #[test]
    fn scan_server_aggregates_all_rules_in_order() {
        let server = json!({
            "command": "npx",
            "args": ["@modelcontextprotocol/server-github", "/"],
            "env": { "API_KEY": "sk-abcdefghijklmnopqrstuvwxyz0123456789" },
            "autoApprove": ["*"]
        });
        let findings = scan_server("gh", &server);
        // CFG-001 (secret), CFG-002 (root), CFG-003 (github no creds), CFG-004 (wildcard).
        assert_eq!(
            ids(&findings),
            vec!["CFG-001", "CFG-002", "CFG-003", "CFG-004"]
        );
    }
}
