//! Data model for the static config scan: findings, per-server risk, and the
//! aggregate report. Kept free of IO so the rules and scoring stay unit
//! testable.

use serde::Serialize;

use crate::security::SecuritySeverity;

/// One security finding against one server entry in a config file.
///
/// The shape mirrors [`crate::security::SecurityFinding`] closely enough that a
/// reporter can treat both uniformly, but the config scan carries a `server`
/// (the `mcpServers` key) and a `remediation` rather than a definition kind,
/// because it reasons about launch configuration, not a live catalog.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ConfigFinding {
    /// Stable rule id, for example `CFG-001`.
    pub rule_id: String,
    /// Short rule name, for example `hardcoded-secret`.
    pub name: String,
    /// Severity on the shared security axis.
    pub severity: SecuritySeverity,
    /// Name of the server entry the finding is about (the `mcpServers` key).
    pub server: String,
    /// One-line human explanation. Never contains a raw secret.
    pub message: String,
    /// The concrete trigger, already safe to print (secrets are redacted).
    pub evidence: String,
    /// One-line fix.
    pub remediation: String,
}

impl crate::waivers::Waivable for ConfigFinding {
    fn rule_id(&self) -> &str {
        &self.rule_id
    }
    fn target(&self) -> &str {
        &self.server
    }
}

/// Risk band for a single server, mirroring agentlint's traffic-light scale.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum RiskBand {
    /// Score 1 to 3: no findings, or only informational ones.
    Green,
    /// Score 4 to 6: review before shipping.
    Yellow,
    /// Score 7 to 10: do not ship without remediation.
    Red,
}

impl RiskBand {
    /// Lowercase label for reporter output.
    pub fn as_str(self) -> &'static str {
        match self {
            RiskBand::Green => "green",
            RiskBand::Yellow => "yellow",
            RiskBand::Red => "red",
        }
    }
}

/// Per-server roll-up: a 1 to 10 score and its band.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ServerRisk {
    /// Server entry name.
    pub server: String,
    /// Risk score from 1 (clean) to 10 (worst).
    pub score: u8,
    /// Traffic-light band derived from the score.
    pub band: RiskBand,
    /// Number of findings that fed the score.
    pub finding_count: usize,
}

/// The full result of scanning one or more config sources.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ConfigScanReport {
    /// Where the scan ran (a file path, or a label for discovered clients).
    pub source: String,
    /// Per-server risk roll-up, sorted by server name for stable output.
    pub servers: Vec<ServerRisk>,
    /// Every finding, flattened across servers, in stable order.
    pub findings: Vec<ConfigFinding>,
}

impl ConfigScanReport {
    /// True when any finding is at or above `threshold`. Drives `--fail-on`.
    pub fn fails_at(&self, threshold: SecuritySeverity) -> bool {
        self.findings.iter().any(|f| f.severity >= threshold)
    }

    /// Highest severity present, or `None` when the report is clean.
    pub fn highest_severity(&self) -> Option<SecuritySeverity> {
        self.findings.iter().map(|f| f.severity).max()
    }
}

/// Risk weight a single severity contributes to a server's score.
fn severity_weight(severity: SecuritySeverity) -> u32 {
    match severity {
        SecuritySeverity::Critical => 7,
        SecuritySeverity::High => 4,
        SecuritySeverity::Medium => 2,
        SecuritySeverity::Low => 1,
        SecuritySeverity::Info => 0,
    }
}

/// Compute the 1 to 10 risk score for a set of findings on one server.
///
/// Baseline 1 (a server entry always "exists"), then each finding adds weight
/// by severity, capped at 10. A single Critical alone reaches the red band, so
/// a hardcoded secret is never reported as merely yellow.
pub fn score_findings(findings: &[&ConfigFinding]) -> u8 {
    let sum: u32 = findings.iter().map(|f| severity_weight(f.severity)).sum();
    (1 + sum).min(10) as u8
}

/// Map a score onto its traffic-light band.
pub fn band_for(score: u8) -> RiskBand {
    match score {
        0..=3 => RiskBand::Green,
        4..=6 => RiskBand::Yellow,
        _ => RiskBand::Red,
    }
}

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

    fn finding(severity: SecuritySeverity) -> ConfigFinding {
        ConfigFinding {
            rule_id: "CFG-000".into(),
            name: "test".into(),
            severity,
            server: "s".into(),
            message: "m".into(),
            evidence: "e".into(),
            remediation: "r".into(),
        }
    }

    #[test]
    fn clean_server_scores_one_and_is_green() {
        assert_eq!(score_findings(&[]), 1);
        assert_eq!(band_for(1), RiskBand::Green);
    }

    #[test]
    fn one_critical_finding_reaches_the_red_band() {
        let f = finding(SecuritySeverity::Critical);
        let score = score_findings(&[&f]);
        assert_eq!(score, 8);
        assert_eq!(band_for(score), RiskBand::Red);
    }

    #[test]
    fn one_high_finding_is_yellow() {
        let f = finding(SecuritySeverity::High);
        let score = score_findings(&[&f]);
        assert_eq!(score, 5);
        assert_eq!(band_for(score), RiskBand::Yellow);
    }

    #[test]
    fn one_medium_finding_stays_green() {
        let f = finding(SecuritySeverity::Medium);
        assert_eq!(band_for(score_findings(&[&f])), RiskBand::Green);
    }

    #[test]
    fn score_is_capped_at_ten() {
        let f = finding(SecuritySeverity::Critical);
        let many = [&f, &f, &f, &f, &f];
        assert_eq!(score_findings(&many), 10);
    }

    #[test]
    fn fails_at_respects_threshold() {
        let report = ConfigScanReport {
            source: "x".into(),
            servers: vec![],
            findings: vec![finding(SecuritySeverity::Medium)],
        };
        assert!(report.fails_at(SecuritySeverity::Medium));
        assert!(report.fails_at(SecuritySeverity::Low));
        assert!(!report.fails_at(SecuritySeverity::High));
        assert_eq!(report.highest_severity(), Some(SecuritySeverity::Medium));
    }
}
