//! GitLab Code Quality reporter.
//!
//! GitLab merges the JSON array emitted here with the same shape from
//! its other quality scanners and surfaces the entries on the merge
//! request page. Each failed or skipped test becomes one entry; passing
//! tests are omitted because Code Quality only reports defects.
//!
//! Severity mapping: a compliance MUST failure renders as
//! `critical`, SHOULD as `major`, MAY as `minor`. A plain test failure
//! (no rule attached) is `major`. Skipped tests stay `minor` because
//! GitLab surfaces them as informational.

use std::io::Write;
use std::sync::Arc;

use serde_json::{json, Value};
use sha2::{Digest, Sha256};

use crate::compliance::RuleSeverity;
use crate::redaction::Redactor;
use crate::report::{Report, Reporter, TestResult, TestStatus};

/// GitLab Code Quality reporter (see module docs).
///
/// Accepts an optional [`Redactor`] via [`GitlabReporter::with_redactor`].
/// When set, the report is redacted before entries are built. The
/// fingerprint hash is computed over the rule identifier plus file and
/// line so cosmetic edits to the test name or failure message do not
/// churn fingerprints across pipeline runs. Prefer [`Report::redacted`]
/// at the dispatch site.
#[derive(Default)]
pub struct GitlabReporter {
    redactor: Option<Arc<Redactor>>,
}

impl GitlabReporter {
    /// Attach a redactor so user-supplied strings render through it.
    pub fn with_redactor(mut self, redactor: Arc<Redactor>) -> Self {
        self.redactor = Some(redactor);
        self
    }
}

impl Reporter for GitlabReporter {
    fn name(&self) -> &'static str {
        "gitlab"
    }

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        let redacted;
        let view: &Report = match &self.redactor {
            Some(r) => {
                redacted = report.redacted(r);
                &redacted
            }
            None => report,
        };
        let entries: Vec<Value> = view.tests.iter().filter_map(build_entry).collect();
        serde_json::to_writer_pretty(&mut *out, &entries).map_err(std::io::Error::other)?;
        writeln!(out)?;
        Ok(())
    }
}

fn build_entry(test: &TestResult) -> Option<Value> {
    let severity = match test.status {
        TestStatus::Fail => severity_for_failure(test),
        TestStatus::Skip => "minor",
        TestStatus::Inconclusive => "info",
        TestStatus::Pass => return None,
    };
    let fingerprint = compute_fingerprint(test);
    let description = match (test.status, &test.failure) {
        (TestStatus::Fail, Some(f)) => format!("{}: {}", test.name, f.message),
        (TestStatus::Fail, None) => format!("{}: test failed", test.name),
        (TestStatus::Skip, _) => format!("{}: skipped", test.name),
        (TestStatus::Inconclusive, _) => format!("{}: inconclusive", test.name),
        (TestStatus::Pass, _) => return None,
    };
    let location = json!({
        "path": test.file.clone().unwrap_or_default(),
        "lines": { "begin": test.line.unwrap_or(1) },
    });
    // GitLab Code Quality has no native timing field; passing tests get no
    // entry at all, so cold timing of healthy tests cannot ride this
    // reporter. For failures we attach `properties.duration_ms` as an
    // extension; the GitLab UI ignores unknown keys, so this is safe
    //. The `check_name` field doubles as the rule id surface so
    // GitLab groups findings under the same rule across pipelines.
    Some(json!({
        "description": description,
        "check_name": test.rule_id.clone().unwrap_or_else(|| "mcptest.assertion".to_owned()),
        "fingerprint": fingerprint,
        "severity": severity,
        "location": location,
        "properties": {
            "duration_ms": test.duration_ms,
            "cache_hit": test.cache_hit,
        },
    }))
}

/// Map a failure to a GitLab severity.  binds RFC 2119 to the
/// GitLab vocabulary: MUST -> critical, SHOULD -> major, MAY -> minor.
/// Tests with no rule attached default to major, preserving the legacy
/// behavior for plain assertion failures.
fn severity_for_failure(test: &TestResult) -> &'static str {
    match test.severity {
        Some(RuleSeverity::Must) => "critical",
        Some(RuleSeverity::Should) => "major",
        Some(RuleSeverity::May) => "minor",
        None => "major",
    }
}

/// Hash `rule_id + file + line` (or `name + file + line` when no rule id
/// is attached) with SHA-256 and render the digest as a 64-char lowercase
/// hex string.
///
/// GitLab uses the fingerprint to deduplicate findings across pipeline
/// runs. The inputs intentionally exclude the failure message and test
/// name when a rule id is present so cosmetic edits do not churn the
/// fingerprint across runs. The null byte separator keeps the hash
/// unambiguous when one field is empty.
fn compute_fingerprint(test: &TestResult) -> String {
    let mut hasher = Sha256::new();
    let stable_key = test.rule_id.as_deref().unwrap_or(test.name.as_str());
    hasher.update(stable_key.as_bytes());
    hasher.update([0u8]);
    hasher.update(test.file.as_deref().unwrap_or("").as_bytes());
    hasher.update([0u8]);
    hasher.update(test.line.unwrap_or(0).to_string().as_bytes());
    let digest = hasher.finalize();
    let mut out = String::with_capacity(64);
    for byte in digest {
        let _ = std::fmt::Write::write_fmt(&mut out, format_args!("{byte:02x}"));
    }
    out
}

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

    #[test]
    fn gitlab_reporter_emits_one_entry_per_failure() {
        let r = Report::sample();
        let mut buf = Vec::new();
        GitlabReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let entries = parsed.as_array().unwrap();
        assert_eq!(entries.len(), 1);
        assert_eq!(entries[0]["severity"], "major");
        assert_eq!(entries[0]["location"]["path"], "tests/create_issue.yml");
        assert_eq!(entries[0]["location"]["lines"]["begin"], 22);
    }

    #[test]
    fn gitlab_reporter_fingerprint_is_64_hex_chars() {
        let r = Report::sample();
        let mut buf = Vec::new();
        GitlabReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let fp = parsed[0]["fingerprint"].as_str().unwrap();
        assert_eq!(fp.len(), 64);
        assert!(fp
            .chars()
            .all(|c| c.is_ascii_hexdigit() && !c.is_uppercase()));
    }

    #[test]
    fn gitlab_reporter_fingerprint_is_stable_across_runs() {
        let r = Report::sample();
        let mut a = Vec::new();
        let mut b = Vec::new();
        GitlabReporter::default().render(&r, &mut a).unwrap();
        GitlabReporter::default().render(&r, &mut b).unwrap();
        assert_eq!(a, b);
    }

    #[test]
    fn gitlab_reporter_handles_zero_failure_report() {
        let r = Report::sample_all_pass();
        let mut buf = Vec::new();
        GitlabReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        let parsed: Value = serde_json::from_str(&s).unwrap();
        assert!(parsed.as_array().unwrap().is_empty());
    }

    #[test]
    fn gitlab_reporter_marks_skips_as_minor() {
        let mut r = Report::sample();
        r.tests[0].status = TestStatus::Skip;
        let mut buf = Vec::new();
        GitlabReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let entries = parsed.as_array().unwrap();
        assert_eq!(entries.len(), 2);
        let minor_count = entries.iter().filter(|e| e["severity"] == "minor").count();
        assert_eq!(minor_count, 1);
    }

    #[test]
    fn gitlab_reporter_name_is_stable() {
        assert_eq!(GitlabReporter::default().name(), "gitlab");
    }

    #[test]
    fn gitlab_reporter_attaches_duration_ms_to_failure_entries() {
        let r = Report::sample();
        let mut buf = Vec::new();
        GitlabReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let entry = &parsed[0];
        assert!(entry["properties"]["duration_ms"].is_number());
        assert!(entry["properties"]["cache_hit"].is_boolean());
    }

    #[test]
    fn gitlab_reporter_redacts_token_shaped_test_name() {
        let mut r = Report::sample();
        r.tests[2].name = "ghp_abcdefghijklmnop1234".into();
        let reporter = GitlabReporter::default().with_redactor(Arc::new(Redactor::new()));
        let mut buf = Vec::new();
        reporter.render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(!s.contains("ghp_abcdefghijklmnop1234"));
        assert!(s.contains("[REDACTED:token]"));
    }

    #[test]
    fn gitlab_reporter_maps_rule_severity_to_critical_major_minor() {
        let r = Report::sample_full();
        let mut buf = Vec::new();
        GitlabReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let entries = parsed.as_array().unwrap();
        let lookup = |rule_id: &str| -> &str {
            entries
                .iter()
                .find(|e| e["check_name"] == rule_id)
                .unwrap_or_else(|| panic!("missing entry for {rule_id}"))["severity"]
                .as_str()
                .unwrap()
        };
        assert_eq!(lookup("PROTO-001"), "critical");
        assert_eq!(lookup("EDGE-005"), "major");
        assert_eq!(lookup("RES-001"), "minor");
    }

    #[test]
    fn gitlab_reporter_fingerprint_excludes_failure_message() {
        // Cosmetic edits to the failure message must not churn the
        // fingerprint, otherwise GitLab opens a new finding each run.
        let mut a = Report::sample_full();
        let mut b = Report::sample_full();
        if let Some(failure) = b.tests[0].failure.as_mut() {
            failure.message = "totally different wording".into();
        }
        let mut buf_a = Vec::new();
        let mut buf_b = Vec::new();
        GitlabReporter::default().render(&a, &mut buf_a).unwrap();
        GitlabReporter::default().render(&b, &mut buf_b).unwrap();
        let parsed_a: Value = serde_json::from_str(&String::from_utf8(buf_a).unwrap()).unwrap();
        let parsed_b: Value = serde_json::from_str(&String::from_utf8(buf_b).unwrap()).unwrap();
        assert_eq!(
            parsed_a[0]["fingerprint"], parsed_b[0]["fingerprint"],
            "fingerprint must not depend on the message text"
        );
        // Touch a sibling so the compiler keeps `a` mutably used and the
        // test reads as symmetric to `b`.
        a.tests[1].cache_hit = true;
    }

    #[test]
    fn gitlab_reporter_fingerprint_includes_rule_id_when_present() {
        // Two failures at the same location with different rule ids must
        // produce different fingerprints, otherwise GitLab will dedupe
        // distinct findings into one row.
        let mut r = Report::sample_full();
        r.tests[1].file = r.tests[0].file.clone();
        r.tests[1].line = r.tests[0].line;
        let mut buf = Vec::new();
        GitlabReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        let entries = parsed.as_array().unwrap();
        let fp_proto = entries
            .iter()
            .find(|e| e["check_name"] == "PROTO-001")
            .unwrap()["fingerprint"]
            .clone();
        let fp_edge = entries
            .iter()
            .find(|e| e["check_name"] == "EDGE-005")
            .unwrap()["fingerprint"]
            .clone();
        assert_ne!(fp_proto, fp_edge);
    }

    #[test]
    fn gitlab_reporter_required_fields_present_on_every_entry() {
        let r = Report::sample_full();
        let mut buf = Vec::new();
        GitlabReporter::default().render(&r, &mut buf).unwrap();
        let parsed: Value = serde_json::from_str(&String::from_utf8(buf).unwrap()).unwrap();
        for entry in parsed.as_array().unwrap() {
            assert!(entry["description"].is_string());
            assert!(entry["fingerprint"].is_string());
            assert!(entry["severity"].is_string());
            assert!(entry["location"]["path"].is_string());
            assert!(entry["location"]["lines"]["begin"].is_number());
            let sev = entry["severity"].as_str().unwrap();
            assert!(matches!(
                sev,
                "info" | "minor" | "major" | "critical" | "blocker"
            ));
        }
    }
}
