//! `control-map` — the deterministic control → scan lookup table (LUT).
//!
//! The "transcribing" layer: it maps each compliance control to the static-scan
//! step(s) that check it, or marks it as needing custom tooling, or as not
//! code-checkable at all. The map is **authored and human-reviewed** — no LLM
//! decides coverage. The LLM only enters later, downstream, to triage/ground the
//! *tool's* findings (that lives in the agent, not here).
//!
//! This crate is intentionally tiny and standalone: types + an embedded JSON LUT
//! + query helpers.

use serde::{Deserialize, Serialize};

/// Coverage bucket for a control under static (SAST-family) scanning.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Coverage {
    /// An existing tool's scan surfaces findings for this control.
    Covered,
    /// Code-checkable, but no existing tool digs it out — we must write tooling.
    NeedsTooling,
    /// Process / document control — out of static-scan scope.
    NotCodeCheckable,
}

/// One tool binding: a scan step that (at least partially) checks a control.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanBinding {
    /// Tool name, e.g. `"semgrep"`, `"gitleaks"`, `"syft"`, `"osv"`.
    pub tool: String,
    /// Scan family, e.g. `"sast"`, `"secret_detection"`, `"sbom"`, `"cve"`.
    pub scan_type: String,
    /// CWEs whose findings map to this control (used to attach findings back).
    #[serde(default)]
    pub cwe: Vec<String>,
    /// Optional specific rule ids this control keys on.
    #[serde(default)]
    pub rules: Vec<String>,
}

/// One control's entry in the LUT.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlEntry {
    /// Control id, e.g. `"cra-ai-8"`.
    pub control: String,
    /// Human-readable title (for the reviewable view).
    #[serde(default)]
    pub title: String,
    /// Coverage bucket.
    pub status: Coverage,
    /// Tool bindings (empty unless `status == Covered`).
    #[serde(default)]
    pub scans: Vec<ScanBinding>,
    /// Reviewer note — why it needs tooling / isn't code-checkable.
    #[serde(default)]
    pub note: Option<String>,
}

/// The control → scan lookup table for one framework.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlMap {
    pub version: String,
    pub framework: String,
    pub controls: Vec<ControlEntry>,
}

const CRA_MAP_JSON: &str = include_str!("../data/cra_control_map.json");

/// Whether an authored rule id `bound` matches a scanner's emitted rule id
/// `actual`. semgrep prefixes local-rule check_ids with a path
/// (`tmp.compliance-cra-semgrep.cra-ai-1-flask-debug-enabled`), so match the final
/// id segment rather than requiring exact equality.
fn rule_id_matches(bound: &str, actual: &str) -> bool {
    actual == bound || actual.ends_with(&format!(".{bound}"))
}

impl ControlMap {
    /// Load the built-in CRA control map (the embedded, authored LUT).
    pub fn cra() -> Result<Self, MapError> {
        Ok(serde_json::from_str(CRA_MAP_JSON)?)
    }

    /// The coverage entry for a control id, if present.
    pub fn coverage(&self, control_id: &str) -> Option<&ControlEntry> {
        self.controls.iter().find(|c| c.control == control_id)
    }

    /// Controls whose bindings include the given `tool` + `cwe` — used to attach a
    /// raw tool finding back to the control(s) it's evidence for.
    pub fn controls_for(&self, tool: &str, cwe: &str) -> Vec<&ControlEntry> {
        self.controls_for_finding(tool, Some(cwe), None)
    }

    /// Controls a tool finding is evidence for, matched by CWE and/or the specific
    /// rule id that fired. Off-the-shelf findings bind by CWE; our custom detectors
    /// bind by rule id (precise — a broad CWE would over-attribute and then the
    /// grounded judge could drop a genuine finding as a control false positive).
    pub fn controls_for_finding(
        &self,
        tool: &str,
        cwe: Option<&str>,
        rule_id: Option<&str>,
    ) -> Vec<&ControlEntry> {
        self.controls
            .iter()
            .filter(|c| {
                c.scans.iter().any(|s| {
                    s.tool == tool
                        && (cwe.is_some_and(|w| s.cwe.iter().any(|x| x == w))
                            || rule_id
                                .is_some_and(|r| s.rules.iter().any(|b| rule_id_matches(b, r))))
                })
            })
            .collect()
    }

    /// Count of controls in each coverage bucket.
    pub fn summary(&self) -> CoverageSummary {
        let mut s = CoverageSummary::default();
        for c in &self.controls {
            match c.status {
                Coverage::Covered => s.covered += 1,
                Coverage::NeedsTooling => s.needs_tooling += 1,
                Coverage::NotCodeCheckable => s.not_code_checkable += 1,
            }
        }
        s
    }
}

/// Coverage bucket counts.
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub struct CoverageSummary {
    pub covered: usize,
    pub needs_tooling: usize,
    pub not_code_checkable: usize,
}

impl CoverageSummary {
    pub fn total(&self) -> usize {
        self.covered + self.needs_tooling + self.not_code_checkable
    }
}

/// Errors loading a control map.
#[derive(Debug, thiserror::Error)]
pub enum MapError {
    #[error("failed to parse control map: {0}")]
    Parse(#[from] serde_json::Error),
}

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

    #[test]
    fn cra_map_loads_all_40_controls() {
        let map = ControlMap::cra().expect("CRA map should parse");
        assert_eq!(map.framework, "cra");
        assert_eq!(map.controls.len(), 40);
        assert_eq!(map.summary().total(), 40);
    }

    #[test]
    fn hardcoded_password_control_is_tool_covered() {
        let map = ControlMap::cra().unwrap();
        let c = map.coverage("cra-ai-8").expect("cra-ai-8 present");
        assert_eq!(c.status, Coverage::Covered);
        assert!(c.scans.iter().any(|s| s.tool == "semgrep"));
        assert!(c.scans.iter().any(|s| s.tool == "gitleaks"));
    }

    #[test]
    fn finding_attaches_back_to_control_via_tool_and_cwe() {
        let map = ControlMap::cra().unwrap();
        let hits = map.controls_for("semgrep", "CWE-798");
        assert!(hits.iter().any(|c| c.control == "cra-ai-8"));
    }

    #[test]
    fn covered_and_not_checkable_are_populated() {
        let s = ControlMap::cra().unwrap().summary();
        assert!(s.covered > 0);
        assert!(s.not_code_checkable > 0);
        // needs_tooling is now empty: every code-checkable control is either
        // tool-covered or covered by the grounded surface pass.
        assert_eq!(s.needs_tooling, 0);
    }

    #[test]
    fn rule_id_matching_handles_semgrep_path_prefix() {
        let bound = "cra-ai-1-flask-debug-enabled";
        assert!(rule_id_matches(bound, bound)); // exact
        assert!(rule_id_matches(
            bound,
            "tmp.compliance-cra-semgrep.cra-ai-1-flask-debug-enabled"
        )); // semgrep path prefix
        assert!(!rule_id_matches(
            bound,
            "cra-ai-1-flask-debug-enabled-extra"
        )); // not a suffix segment
        assert!(!rule_id_matches(
            bound,
            "python.lang.security.exec-detected"
        )); // unrelated
    }

    #[test]
    fn custom_rule_finding_attaches_to_control_by_rule_id() {
        let map = ControlMap::cra().unwrap();
        // cra-ai-1 is now tool-covered by custom rules.
        assert_eq!(map.coverage("cra-ai-1").unwrap().status, Coverage::Covered);
        // A prefixed check_id still maps back to cra-ai-1 by rule id.
        let hits =
            map.controls_for_finding("semgrep", None, Some("tmp.x.cra-ai-1-tls-verify-disabled"));
        assert!(hits.iter().any(|c| c.control == "cra-ai-1"));
    }

    #[test]
    fn coverage_after_grounded_promotion() {
        let s = ControlMap::cra().unwrap().summary();
        // 9 off-the-shelf + 4 custom-semgrep + 8 grounded surface controls (promoted
        // after the grounded path was validated live).
        assert_eq!(s.covered, 21);
        // Nothing left as needs_tooling — every code-checkable control is covered.
        assert_eq!(s.needs_tooling, 0);
        // The 4 pure-architectural controls remain not code-checkable.
        assert_eq!(s.not_code_checkable, 19);
        assert_eq!(s.total(), 40);
    }

    #[test]
    fn architectural_controls_are_not_code_checkable() {
        let map = ControlMap::cra().unwrap();
        for id in ["cra-ai-2", "cra-ai-3", "cra-ai-4", "cra-ai-5"] {
            let c = map.coverage(id).unwrap();
            assert_eq!(c.status, Coverage::NotCodeCheckable, "{id}");
            assert!(c.scans.is_empty(), "{id} should carry no scan bindings");
        }
    }

    #[test]
    fn custom_rule_controls_do_not_bind_by_broad_cwe() {
        let map = ControlMap::cra().unwrap();
        // cra-ai-1 rules emit CWE-489 in metadata, but the LUT binds by rule id
        // only (cwe: []) — so a stray CWE-489 finding must NOT attach to it.
        assert!(map.controls_for("semgrep", "CWE-489").is_empty());
        // The CWE path for off-the-shelf findings is unchanged.
        assert!(map
            .controls_for("semgrep", "CWE-798")
            .iter()
            .any(|c| c.control == "cra-ai-8"));
    }
}
