//! The "recognize" stage: judge whether a code region violates a control.
//!
//! Behind the [`ControlJudge`] trait so the grounded checker can be driven by a
//! deterministic stub in tests. The real [`LlmControlJudge`] runs the model at
//! temperature 0 with a closed prompt — it must quote the offending code VERBATIM,
//! and everything it returns is then re-checked by the grounding gate
//! ([`compliance_core::control_check::ground`]). The judge is allowed to be
//! smart; it is never trusted.

use std::sync::Arc;

use serde::Deserialize;

use compliance_core::control_check::{CandidateRegion, ControlCheckSpec, LlmVerdict};

use crate::llm::LlmClient;

/// Prompt/logic version — part of the verdict cache key, bump on any change here.
pub const PROMPT_VERSION: &str = "control-judge-v1";

const SYSTEM_PROMPT: &str = "You are a precise security & compliance code auditor. \
You are given ONE compliance control (a requirement) and ONE code region. Decide \
ONLY whether the code region VIOLATES the control. Rules: (1) Judge only the code \
shown — never assume code that is not present. (2) If and only if it violates, copy \
the EXACT offending code VERBATIM into `snippet`, character-for-character from the \
region — do not paraphrase, reformat, or reconstruct it. (3) If it does not clearly \
violate, set violates=false and leave snippet empty. (4) Prefer false over guessing. \
Respond with STRICT JSON only, no prose: \
{\"violates\": bool, \"snippet\": \"<verbatim code or empty>\", \"cwe\": \"CWE-NNN or null\", \"confidence\": 0.0-1.0}";

/// Judges one (control, region). Async-in-trait so a stub can drive tests.
#[allow(async_fn_in_trait)]
pub trait ControlJudge: Send + Sync {
    async fn judge(&self, spec: &ControlCheckSpec, region: &CandidateRegion) -> LlmVerdict;
}

/// The real judge: the LLM at temperature 0 with the closed, verbatim-snippet prompt.
pub struct LlmControlJudge {
    llm: Arc<LlmClient>,
}

impl LlmControlJudge {
    pub fn new(llm: Arc<LlmClient>) -> Self {
        Self { llm }
    }
}

impl ControlJudge for LlmControlJudge {
    async fn judge(&self, spec: &ControlCheckSpec, region: &CandidateRegion) -> LlmVerdict {
        let user = build_user_prompt(spec, region);
        match self.llm.chat(SYSTEM_PROMPT, &user, Some(0.0)).await {
            Ok(response) => parse_verdict(&response),
            Err(e) => {
                // Fail closed: a transient model error yields no finding, never a
                // fabricated one.
                tracing::warn!(control = %spec.control_id, error = %e, "control judge call failed");
                no_violation()
            }
        }
    }
}

fn build_user_prompt(spec: &ControlCheckSpec, region: &CandidateRegion) -> String {
    format!(
        "CONTROL {id} — {title}\nRequirement: {req}\n\nCODE ({file}, first line = {line}):\n```\n{code}\n```\n\nReturn the JSON verdict.",
        id = spec.control_id,
        title = spec.title,
        req = spec.requirement,
        file = region.file,
        line = region.start_line,
        code = region.content,
    )
}

#[derive(Debug, Default, Deserialize)]
struct RawVerdict {
    #[serde(default)]
    violates: bool,
    #[serde(default)]
    snippet: String,
    #[serde(default)]
    cwe: Option<String>,
    #[serde(default)]
    confidence: f64,
}

/// Parse the model's JSON verdict, tolerant of ```json fencing. Any parse failure
/// degrades to a non-violation (never a fabricated finding).
fn parse_verdict(response: &str) -> LlmVerdict {
    let cleaned = response
        .trim()
        .trim_start_matches("```json")
        .trim_start_matches("```")
        .trim_end_matches("```")
        .trim();
    match serde_json::from_str::<RawVerdict>(cleaned) {
        Ok(raw) => LlmVerdict {
            violates: raw.violates,
            snippet: raw.snippet,
            cwe: raw.cwe.filter(|c| !c.trim().is_empty()),
            confidence: raw.confidence,
        },
        Err(e) => {
            tracing::debug!(error = %e, "failed to parse control verdict; treating as non-violation");
            no_violation()
        }
    }
}

fn no_violation() -> LlmVerdict {
    LlmVerdict {
        violates: false,
        snippet: String::new(),
        cwe: None,
        confidence: 0.0,
    }
}

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

    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,
        }
    }

    #[test]
    fn parses_plain_and_fenced_json() {
        let plain = r#"{"violates": true, "snippet": "PASSWORD = \"x\"", "cwe": "CWE-798", "confidence": 0.9}"#;
        let v = parse_verdict(plain);
        assert!(v.violates);
        assert_eq!(v.snippet, "PASSWORD = \"x\"");
        assert_eq!(v.cwe.as_deref(), Some("CWE-798"));

        let fenced = "```json\n{\"violates\": false, \"snippet\": \"\", \"cwe\": null, \"confidence\": 0.1}\n```";
        assert!(!parse_verdict(fenced).violates);
    }

    #[test]
    fn garbage_and_empty_cwe_are_safe() {
        assert!(!parse_verdict("not json at all").violates); // fail closed
        let no_cwe =
            parse_verdict(r#"{"violates": true, "snippet": "x", "cwe": "  ", "confidence": 0.5}"#);
        assert!(no_cwe.cwe.is_none()); // blank CWE normalised away
    }

    #[test]
    fn user_prompt_carries_control_and_code() {
        let region = CandidateRegion {
            file: "src/auth.py".into(),
            start_line: 10,
            content: "PASSWORD = \"admin\"".into(),
        };
        let p = build_user_prompt(&spec(), &region);
        assert!(p.contains("cra-ai-8"));
        assert!(p.contains("Products must not ship default credentials"));
        assert!(p.contains("PASSWORD = \"admin\""));
        assert!(p.contains("src/auth.py"));
    }
}
