use std::path::{Path, PathBuf};

use compliance_core::models::{Finding, ScanType, Severity};
use compliance_core::traits::{ScanOutput, Scanner};
use compliance_core::CoreError;

use crate::pipeline::dedup;

/// Custom CRA-control detectors bundled into the binary and staged to a temp file
/// at scan time so semgrep can `--config` them alongside the auto ruleset. These
/// cover controls no off-the-shelf rule digs out (secure defaults, weak password
/// hashing, insecure session cookies, weak data-at-rest ciphers); each rule id is
/// keyed back to its control by the `control-map` LUT.
const CRA_RULES: &str = include_str!("../../rules/cra_semgrep.yaml");

/// Write the bundled CRA rules to a stable temp path (atomic: unique tmp +
/// rename). Returns `None` on failure — the scan then runs with auto rules only.
async fn stage_cra_rules() -> Option<PathBuf> {
    let dir = std::env::temp_dir();
    let path = dir.join("compliance-cra-semgrep.yaml");
    let tmp = dir.join(format!("compliance-cra-semgrep.{}.tmp", std::process::id()));
    if let Err(e) = tokio::fs::write(&tmp, CRA_RULES).await {
        tracing::warn!(error = %e, "failed to stage custom CRA semgrep rules; using auto rules only");
        return None;
    }
    if let Err(e) = tokio::fs::rename(&tmp, &path).await {
        tracing::warn!(error = %e, "failed to stage custom CRA semgrep rules; using auto rules only");
        return None;
    }
    Some(path)
}

pub struct SemgrepScanner;

impl Scanner for SemgrepScanner {
    fn name(&self) -> &str {
        "semgrep"
    }

    fn scan_type(&self) -> ScanType {
        ScanType::Sast
    }

    #[tracing::instrument(skip_all)]
    async fn scan(&self, repo_path: &Path, repo_id: &str) -> Result<ScanOutput, CoreError> {
        let cra_rules = stage_cra_rules().await;
        let mut command = tokio::process::Command::new("semgrep");
        command.arg("--config=auto");
        if let Some(path) = &cra_rules {
            command.arg(format!("--config={}", path.display()));
        }
        command
            .args(["--json", "--quiet", "--max-memory", "500", "--jobs", "1"])
            .arg(repo_path);

        let output = tokio::time::timeout(std::time::Duration::from_secs(600), command.output())
            .await
            .map_err(|_| CoreError::Scanner {
                scanner: "semgrep".to_string(),
                source: "timed out after 10 minutes".into(),
            })?
            .map_err(|e| CoreError::Scanner {
                scanner: "semgrep".to_string(),
                source: Box::new(e),
            })?;

        if !output.status.success() && output.stdout.is_empty() {
            let stderr = String::from_utf8_lossy(&output.stderr);
            tracing::warn!("Semgrep exited with {}: {stderr}", output.status);
            return Ok(ScanOutput::default());
        }

        let result: SemgrepOutput = serde_json::from_slice(&output.stdout)?;
        let findings = result
            .results
            .into_iter()
            .map(|r| {
                let severity = match r.extra.severity.as_str() {
                    "ERROR" => Severity::High,
                    "WARNING" => Severity::Medium,
                    "INFO" => Severity::Low,
                    _ => Severity::Info,
                };

                let fingerprint = dedup::compute_fingerprint(&[
                    repo_id,
                    &r.check_id,
                    &r.path,
                    &r.start.line.to_string(),
                ]);

                let mut finding = Finding::new(
                    repo_id.to_string(),
                    fingerprint,
                    "semgrep".to_string(),
                    ScanType::Sast,
                    r.extra.message.clone(),
                    r.extra.message,
                    severity,
                );
                finding.rule_id = Some(r.check_id);
                finding.file_path = Some(r.path);
                finding.line_number = Some(r.start.line);
                finding.code_snippet = Some(r.extra.lines);
                finding.cwe = r.extra.metadata.as_ref().and_then(extract_cwe);
                finding
            })
            .collect();

        Ok(ScanOutput {
            findings,
            sbom_entries: Vec::new(),
        })
    }
}

#[derive(serde::Deserialize)]
struct SemgrepOutput {
    results: Vec<SemgrepResult>,
}

#[derive(serde::Deserialize)]
struct SemgrepResult {
    check_id: String,
    path: String,
    start: SemgrepPosition,
    extra: SemgrepExtra,
}

#[derive(serde::Deserialize)]
struct SemgrepPosition {
    line: u32,
}

#[derive(serde::Deserialize)]
struct SemgrepExtra {
    message: String,
    severity: String,
    lines: String,
    #[serde(default)]
    metadata: Option<serde_json::Value>,
}

/// semgrep emits `metadata.cwe` as a list of strings like
/// `"CWE-798: Use of Hard-coded Credentials"` (occasionally a bare string). Take
/// the first entry and normalise it to just the `CWE-NNN` id.
fn extract_cwe(metadata: &serde_json::Value) -> Option<String> {
    let raw = metadata.get("cwe")?;
    let text = match raw {
        serde_json::Value::Array(items) => items.first()?.as_str()?,
        serde_json::Value::String(s) => s.as_str(),
        _ => return None,
    };
    let id = text.split(':').next().unwrap_or(text).trim();
    (!id.is_empty()).then(|| id.to_string())
}

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

    #[test]
    fn extract_cwe_handles_list_and_normalises() {
        let md = serde_json::json!({"cwe": ["CWE-798: Use of Hard-coded Credentials"]});
        assert_eq!(extract_cwe(&md).as_deref(), Some("CWE-798"));
        let bare = serde_json::json!({"cwe": "CWE-89"});
        assert_eq!(extract_cwe(&bare).as_deref(), Some("CWE-89"));
        let none = serde_json::json!({"severity": "ERROR"});
        assert_eq!(extract_cwe(&none), None);
    }

    #[test]
    fn deserialize_semgrep_output() {
        let json = r#"{
            "results": [
                {
                    "check_id": "python.lang.security.audit.exec-detected",
                    "path": "src/main.py",
                    "start": {"line": 15},
                    "extra": {
                        "message": "Detected use of exec()",
                        "severity": "ERROR",
                        "lines": "exec(user_input)",
                        "metadata": {"cwe": "CWE-78"}
                    }
                }
            ]
        }"#;
        let output: SemgrepOutput = serde_json::from_str(json).unwrap();
        assert_eq!(output.results.len(), 1);

        let r = &output.results[0];
        assert_eq!(r.check_id, "python.lang.security.audit.exec-detected");
        assert_eq!(r.path, "src/main.py");
        assert_eq!(r.start.line, 15);
        assert_eq!(r.extra.message, "Detected use of exec()");
        assert_eq!(r.extra.severity, "ERROR");
        assert_eq!(r.extra.lines, "exec(user_input)");
        assert_eq!(
            r.extra
                .metadata
                .as_ref()
                .unwrap()
                .get("cwe")
                .unwrap()
                .as_str(),
            Some("CWE-78")
        );
    }

    #[test]
    fn deserialize_semgrep_empty_results() {
        let json = r#"{"results": []}"#;
        let output: SemgrepOutput = serde_json::from_str(json).unwrap();
        assert!(output.results.is_empty());
    }

    #[test]
    fn deserialize_semgrep_no_metadata() {
        let json = r#"{
            "results": [
                {
                    "check_id": "rule-1",
                    "path": "app.py",
                    "start": {"line": 1},
                    "extra": {
                        "message": "found something",
                        "severity": "WARNING",
                        "lines": "import os"
                    }
                }
            ]
        }"#;
        let output: SemgrepOutput = serde_json::from_str(json).unwrap();
        assert!(output.results[0].extra.metadata.is_none());
    }

    #[test]
    fn semgrep_severity_mapping() {
        let cases = vec![
            ("ERROR", "High"),
            ("WARNING", "Medium"),
            ("INFO", "Low"),
            ("UNKNOWN", "Info"),
        ];
        for (input, expected) in cases {
            let result = match input {
                "ERROR" => "High",
                "WARNING" => "Medium",
                "INFO" => "Low",
                _ => "Info",
            };
            assert_eq!(result, expected, "Severity for '{input}'");
        }
    }

    #[test]
    fn deserialize_semgrep_multiple_results() {
        let json = r#"{
            "results": [
                {
                    "check_id": "rule-a",
                    "path": "a.py",
                    "start": {"line": 1},
                    "extra": {
                        "message": "msg a",
                        "severity": "ERROR",
                        "lines": "line a"
                    }
                },
                {
                    "check_id": "rule-b",
                    "path": "b.py",
                    "start": {"line": 99},
                    "extra": {
                        "message": "msg b",
                        "severity": "INFO",
                        "lines": "line b"
                    }
                }
            ]
        }"#;
        let output: SemgrepOutput = serde_json::from_str(json).unwrap();
        assert_eq!(output.results.len(), 2);
        assert_eq!(output.results[1].start.line, 99);
    }
}
