//! Grounded control-driven checking.
//!
//! Turns a *text* control into findings via an LLM used as a **pattern-recognizer**
//! whose output is grounded to real code — so a hallucinated finding cannot
//! survive. Determinism is structural, not a prompt plea:
//!
//! 1. the LLM only ever judges *retrieved* regions — it can't invent findings in
//!    code it never saw;
//! 2. a verdict becomes a finding only if its quoted snippet appears **verbatim**
//!    in the region, and the line is recomputed from that match — the model's own
//!    line number is never trusted ([`ground`]);
//! 3. verdicts are cached by content hash ([`cache_key`]) so re-scans reproduce.
//!
//! The LLM supplies cross-language / cross-stack pattern recognition; this module
//! supplies the determinism.

use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

use crate::models::finding::{Finding, Severity};
use crate::models::scan::ScanType;

/// A control rendered as a check the LLM judges code against.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ControlCheckSpec {
    /// Stable control id, e.g. `"cra-ai-8"`.
    pub control_id: String,
    /// Short control title (used in the finding title).
    pub title: String,
    /// The requirement text the LLM judges against (control objective/statement).
    pub requirement: String,
    /// CWE to fall back to when the model doesn't supply one.
    pub default_cwe: Option<String>,
    /// Severity for findings raised from this control.
    pub severity: Severity,
}

/// A retrieved code region the LLM judges — never the whole repo.
#[derive(Debug, Clone)]
pub struct CandidateRegion {
    /// Repo-relative path.
    pub file: String,
    /// 1-based line number of the region's first line in `file`.
    pub start_line: u32,
    /// The region's source text.
    pub content: String,
}

/// The LLM's structured verdict for one (control, region). `snippet` is the
/// verbatim code the model claims proves the violation — it is the anchor the
/// grounding gate checks.
#[derive(Debug, Clone)]
pub struct LlmVerdict {
    pub violates: bool,
    pub snippet: String,
    pub cwe: Option<String>,
    pub confidence: f64,
}

/// The grounding gate. A verdict becomes a [`Finding`] only if it claims a
/// violation AND its quoted `snippet` appears verbatim in `region.content`; the
/// finding's line is computed from the match, so a fabricated or mis-located
/// snippet is dropped. Pure — no LLM, no I/O.
pub fn ground(
    spec: &ControlCheckSpec,
    region: &CandidateRegion,
    verdict: &LlmVerdict,
    repo_id: &str,
) -> Option<Finding> {
    if !verdict.violates {
        return None;
    }
    let snippet = verdict.snippet.trim();
    if snippet.is_empty() {
        return None;
    }
    // Grounding: the quoted snippet must literally exist in the retrieved region.
    let pos = region.content.find(snippet)?;
    // Recompute the real line from the match — never trust the model's number.
    let newlines_before = region.content[..pos].matches('\n').count();
    let line = region.start_line + newlines_before as u32;

    let mut finding = Finding::new(
        repo_id.to_string(),
        control_finding_fingerprint(&spec.control_id, &region.file, snippet),
        "control-check".to_string(),
        ScanType::CodeReview,
        format!("{}: {}", spec.control_id, spec.title),
        format!(
            "Control {} appears violated ({}) at {}:{line}",
            spec.control_id, spec.requirement, region.file
        ),
        spec.severity.clone(),
    );
    finding.cwe = verdict.cwe.clone().or_else(|| spec.default_cwe.clone());
    finding.file_path = Some(region.file.clone());
    finding.line_number = Some(line);
    finding.code_snippet = Some(snippet.to_string());
    finding.confidence = Some(verdict.confidence);
    // Carry the control reference on the finding.
    finding.control_refs = vec![spec.control_id.clone()];
    Some(finding)
}

/// Deterministic cache key for a (control, region, model, prompt-version) verdict
/// so identical inputs reproduce the same verdict without another LLM call.
pub fn cache_key(
    control_id: &str,
    region_content: &str,
    model: &str,
    prompt_version: &str,
) -> String {
    hash_parts(&[control_id, region_content, model, prompt_version])
}

fn control_finding_fingerprint(control_id: &str, file: &str, snippet: &str) -> String {
    hash_parts(&[control_id, file, snippet])
}

fn hash_parts(parts: &[&str]) -> String {
    let mut hasher = Sha256::new();
    for part in parts {
        hasher.update(part.as_bytes());
        hasher.update([0u8]); // domain separator between parts
    }
    hex::encode(hasher.finalize())
}

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

    fn spec() -> ControlCheckSpec {
        ControlCheckSpec {
            control_id: "cra-ai-8".into(),
            title: "No default passwords".into(),
            requirement: "Products must not ship default credentials".into(),
            default_cwe: Some("CWE-798".into()),
            severity: Severity::High,
        }
    }

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

    #[test]
    fn grounds_real_snippet_with_recomputed_line() {
        let v = LlmVerdict {
            violates: true,
            snippet: "PASSWORD = \"admin123\"".into(),
            cwe: None,
            confidence: 0.9,
        };
        let f = ground(&spec(), &region(), &v, "repo").expect("should ground");
        assert_eq!(f.line_number, Some(11)); // 2nd line of a region starting at 10
        assert_eq!(f.cwe.as_deref(), Some("CWE-798")); // fell back to the spec default
        assert_eq!(f.control_refs, vec!["cra-ai-8".to_string()]); // control ref carried
        assert_eq!(f.file_path.as_deref(), Some("src/auth.py"));
        assert_eq!(f.code_snippet.as_deref(), Some("PASSWORD = \"admin123\""));
    }

    #[test]
    fn drops_fabricated_snippet_not_in_region() {
        let v = LlmVerdict {
            violates: true,
            snippet: "SECRET = \"totally-made-up\"".into(),
            cwe: None,
            confidence: 0.99,
        };
        assert!(ground(&spec(), &region(), &v, "repo").is_none());
    }

    #[test]
    fn drops_non_violation_and_empty_snippet() {
        let no = LlmVerdict {
            violates: false,
            snippet: "PASSWORD = \"admin123\"".into(),
            cwe: None,
            confidence: 0.9,
        };
        assert!(ground(&spec(), &region(), &no, "repo").is_none());
        let empty = LlmVerdict {
            violates: true,
            snippet: "   ".into(),
            cwe: None,
            confidence: 0.9,
        };
        assert!(ground(&spec(), &region(), &empty, "repo").is_none());
    }

    #[test]
    fn cache_key_and_fingerprint_are_deterministic() {
        assert_eq!(cache_key("c", "x", "m", "v"), cache_key("c", "x", "m", "v"));
        assert_ne!(cache_key("c", "x", "m", "v"), cache_key("c", "y", "m", "v"));
        assert_eq!(
            control_finding_fingerprint("c", "f", "s"),
            control_finding_fingerprint("c", "f", "s")
        );
    }
}
