//! Static security scan of MCP client/host config files.
//!
//! A pre-deploy check in the spirit of agentlint: read an `mcp.json` or
//! `claude_desktop_config.json` and emit findings (hardcoded secrets, broad
//! filesystem access, missing credentials, auto-approved tools) plus a
//! per-server risk score, without ever launching a server. It complements the
//! live security lane, which needs a running server, by catching deployment
//! misconfiguration earlier.

mod model;
mod render;
mod rules;

use std::collections::BTreeMap;

use serde_json::Value;

pub use model::{band_for, score_findings, ConfigFinding, ConfigScanReport, RiskBand, ServerRisk};
pub use render::{render_pretty, to_html, to_sarif};
pub use rules::scan_server;

/// Parse a config file body into its `(name, server-object)` entries.
///
/// Accepts both the `mcpServers` map (Claude Desktop, Claude Code, Cursor,
/// Windsurf, Codex) and the `servers` alias (VS Code); an `mcpServers` entry
/// wins a name collision. Entries come back sorted by name. Each server's raw
/// [`Value`] is preserved so client-specific keys (for example `autoApprove`)
/// remain visible to the rules.
pub fn parse_config(body: &str) -> Result<Vec<(String, Value)>, serde_json::Error> {
    #[derive(serde::Deserialize)]
    struct Doc {
        #[serde(default, rename = "mcpServers")]
        mcp_servers: BTreeMap<String, Value>,
        #[serde(default)]
        servers: BTreeMap<String, Value>,
    }
    let doc: Doc = serde_json::from_str(body)?;
    let mut merged = doc.servers;
    merged.extend(doc.mcp_servers);
    Ok(merged.into_iter().collect())
}

/// Scan a set of `(name, server-object)` entries into a report.
///
/// `source` labels where the entries came from (a file path, or a label such
/// as "discovered clients") and is echoed in the rendered output.
pub fn scan_entries(source: &str, entries: &[(String, Value)]) -> ConfigScanReport {
    let mut findings = Vec::new();
    let mut servers = Vec::new();
    for (name, server) in entries {
        let server_findings = scan_server(name, server);
        let refs: Vec<&ConfigFinding> = server_findings.iter().collect();
        let score = score_findings(&refs);
        servers.push(ServerRisk {
            server: name.clone(),
            score,
            band: band_for(score),
            finding_count: server_findings.len(),
        });
        findings.extend(server_findings);
    }
    ConfigScanReport {
        source: source.to_string(),
        servers,
        findings,
    }
}

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

    #[test]
    fn parse_config_reads_both_mcpservers_and_servers_keys() {
        let body = r#"{
            "mcpServers": { "a": { "command": "x" } },
            "servers":    { "b": { "command": "y" } }
        }"#;
        let entries = parse_config(body).expect("parses");
        let names: Vec<&str> = entries.iter().map(|(n, _)| n.as_str()).collect();
        assert_eq!(names, vec!["a", "b"]);
    }

    #[test]
    fn scan_entries_rolls_up_per_server_risk() {
        let entries = parse_config(
            r#"{ "mcpServers": {
                "danger": {
                    "command": "npx",
                    "args": ["@modelcontextprotocol/server-filesystem", "/"],
                    "env": { "API_KEY": "sk-abcdefghijklmnopqrstuvwxyz0123456789" }
                },
                "safe": { "command": "node", "args": ["./server.js"] }
            } }"#,
        )
        .expect("parses");
        let report = scan_entries("mcp.json", &entries);

        assert_eq!(report.servers.len(), 2);
        let danger = report
            .servers
            .iter()
            .find(|s| s.server == "danger")
            .unwrap();
        let safe = report.servers.iter().find(|s| s.server == "safe").unwrap();
        assert_eq!(danger.band, RiskBand::Red);
        assert_eq!(safe.band, RiskBand::Green);
        assert!(report.fails_at(SecuritySeverity::Critical));
    }
}
