//! Triage step: confirm/refute a deterministic tool finding against the controls
//! it maps to (via the `control-map` LUT), grounding the judgment.
//!
//! This is where the LLM finally enters — as a **false-positive filter over tool
//! output**, never as the detector (the ZeroFalse / IRIS pattern). A tool
//! (semgrep, gitleaks, syft/osv) detects deterministically; `controls_for(tool,
//! cwe)` attaches the finding to the control(s) it's evidence for; the grounded
//! judge then confirms or refutes each, and only judgments anchored to real code
//! survive.

use std::collections::HashMap;

use compliance_core::control_check::{ground, CandidateRegion, ControlCheckSpec};
use compliance_core::models::Finding;
use control_map::ControlMap;

use super::judge::ControlJudge;

/// What triage decided for one tool finding.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TriageOutcome {
    /// The finding maps to no control in the LUT — keep it, untagged.
    Unmapped,
    /// Maps to controls and the grounded judge confirmed at least one — keep the
    /// finding and tag it with these control ids.
    Confirmed(Vec<String>),
    /// Maps to controls but the judge grounded none — treat as a false positive.
    FalsePositive,
}

/// Triages tool findings against the control map, confirming with a grounded judge.
pub struct ControlTriage<J> {
    judge: J,
    map: ControlMap,
    /// Control requirement specs (by control id), built from the ingested catalog.
    specs: HashMap<String, ControlCheckSpec>,
}

impl<J: ControlJudge> ControlTriage<J> {
    pub fn new(judge: J, map: ControlMap, specs: HashMap<String, ControlCheckSpec>) -> Self {
        Self { judge, map, specs }
    }

    /// Triage one tool finding. `region` is the code around the finding, used as
    /// the grounding evidence for the judge.
    pub async fn triage(&self, finding: &Finding, region: &CandidateRegion) -> TriageOutcome {
        // Match by CWE (off-the-shelf findings) and/or rule id (our custom
        // detectors, which carry no LUT-bound CWE). A finding with neither is
        // simply unmapped.
        let mapped = self.map.controls_for_finding(
            &finding.scanner,
            finding.cwe.as_deref(),
            finding.rule_id.as_deref(),
        );
        if mapped.is_empty() {
            return TriageOutcome::Unmapped;
        }

        let mut confirmed = Vec::new();
        for entry in mapped {
            let Some(spec) = self.specs.get(&entry.control) else {
                continue;
            };
            let verdict = self.judge.judge(spec, region).await;
            // The verdict only counts if it grounds to real code in the region.
            if ground(spec, region, &verdict, &finding.repo_id).is_some() {
                confirmed.push(entry.control.clone());
            }
        }

        if confirmed.is_empty() {
            TriageOutcome::FalsePositive
        } else {
            TriageOutcome::Confirmed(confirmed)
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use compliance_core::control_check::LlmVerdict;
    use compliance_core::models::finding::Severity;
    use compliance_core::models::scan::ScanType;

    struct StubJudge {
        verdict: LlmVerdict,
    }
    impl ControlJudge for StubJudge {
        async fn judge(&self, _s: &ControlCheckSpec, _r: &CandidateRegion) -> LlmVerdict {
            self.verdict.clone()
        }
    }

    fn specs() -> HashMap<String, ControlCheckSpec> {
        let mut m = HashMap::new();
        m.insert(
            "cra-ai-8".to_string(),
            ControlCheckSpec {
                control_id: "cra-ai-8".into(),
                title: "No default passwords".into(),
                requirement: "No default credentials".into(),
                default_cwe: Some("CWE-798".into()),
                severity: Severity::High,
            },
        );
        m
    }

    fn semgrep_finding(cwe: &str) -> Finding {
        let mut f = Finding::new(
            "repo".into(),
            "fp1".into(),
            "semgrep".into(),
            ScanType::Sast,
            "hardcoded credential".into(),
            "desc".into(),
            Severity::High,
        );
        f.cwe = Some(cwe.into());
        f
    }

    fn region() -> CandidateRegion {
        CandidateRegion {
            file: "src/auth.py".into(),
            start_line: 1,
            content: "PASSWORD = \"admin123\"\n".into(),
        }
    }

    #[tokio::test]
    async fn confirmed_finding_is_tagged_with_control() {
        let triage = ControlTriage::new(
            StubJudge {
                verdict: LlmVerdict {
                    violates: true,
                    snippet: "PASSWORD = \"admin123\"".into(),
                    cwe: None,
                    confidence: 0.9,
                },
            },
            ControlMap::cra().unwrap(),
            specs(),
        );
        let out = triage.triage(&semgrep_finding("CWE-798"), &region()).await;
        assert_eq!(out, TriageOutcome::Confirmed(vec!["cra-ai-8".to_string()]));
    }

    #[tokio::test]
    async fn refuted_mapped_finding_is_false_positive() {
        // Maps to cra-ai-8, but the judge doesn't confirm (no violation) → FP.
        let triage = ControlTriage::new(
            StubJudge {
                verdict: LlmVerdict {
                    violates: false,
                    snippet: String::new(),
                    cwe: None,
                    confidence: 0.1,
                },
            },
            ControlMap::cra().unwrap(),
            specs(),
        );
        let out = triage.triage(&semgrep_finding("CWE-798"), &region()).await;
        assert_eq!(out, TriageOutcome::FalsePositive);
    }

    #[tokio::test]
    async fn custom_rule_finding_without_cwe_is_confirmed() {
        // A custom detector finding carries a rule id but no LUT-bound CWE; it must
        // still map (by rule id) and confirm.
        let mut specs = specs();
        specs.insert(
            "cra-ai-1".to_string(),
            ControlCheckSpec {
                control_id: "cra-ai-1".into(),
                title: "Secure-by-Default".into(),
                requirement: "Ship secure defaults".into(),
                default_cwe: None,
                severity: Severity::Medium,
            },
        );
        let triage = ControlTriage::new(
            StubJudge {
                verdict: LlmVerdict {
                    violates: true,
                    snippet: "app.run(debug=True)".into(),
                    cwe: None,
                    confidence: 0.9,
                },
            },
            ControlMap::cra().unwrap(),
            specs,
        );
        let mut f = Finding::new(
            "repo".into(),
            "fp".into(),
            "semgrep".into(),
            ScanType::Sast,
            "flask debug".into(),
            "desc".into(),
            Severity::Medium,
        );
        f.rule_id = Some("tmp.x.cra-ai-1-flask-debug-enabled".into()); // no cwe
        let region = CandidateRegion {
            file: "app.py".into(),
            start_line: 1,
            content: "app.run(debug=True)\n".into(),
        };
        let out = triage.triage(&f, &region).await;
        assert_eq!(out, TriageOutcome::Confirmed(vec!["cra-ai-1".to_string()]));
    }

    #[tokio::test]
    async fn unmapped_cwe_is_left_untagged() {
        let triage = ControlTriage::new(
            StubJudge {
                verdict: LlmVerdict {
                    violates: true,
                    snippet: "PASSWORD = \"admin123\"".into(),
                    cwe: None,
                    confidence: 0.9,
                },
            },
            ControlMap::cra().unwrap(),
            specs(),
        );
        let out = triage
            .triage(&semgrep_finding("CWE-99999"), &region())
            .await;
        assert_eq!(out, TriageOutcome::Unmapped);
    }
}
