//! JUnit XML reporter.
//!
//! Emits the JUnit XML shape consumed by GitHub Actions
//! (`dorny/test-reporter`), GitLab CI, Jenkins, CircleCI Insights,
//! and most other CI dashboards. The structural surface this reporter
//! commits to:
//!
//! * One `<testsuite>` per source YAML file (`name = file path`) so
//!   filtering by suite in CI groups tests by the file they live in.
//! * Aggregate `tests`, `failures`, `errors`, `skipped`, and `time`
//!   counts on each `<testsuite>` and on the root `<testsuites>`.
//! * Each `<testcase>` carries `name`, `classname` (the YAML file
//!   path, mirroring how Java reporters use `classname`), and a
//!   `time` attribute formatted to three decimal places of seconds.
//! * Cache hits render as passes; the cache-hit signal lives on a
//!   `<properties><property name="cache_hit" value="true"/>` child
//!   instead of pretending the test was skipped. Acceptance criterion
//!   on .
//! * Failures render as `<failure type="..." message="...">` with the
//!   diff body inside a CDATA section, truncated to 64 KB so we never
//!   produce a single testcase that breaks the parser's per-element
//!   buffer.
//! * Skipped tests use the empty `<skipped/>` element.
//!
//! The reporter is paranoid about producing junk: every user-supplied
//! string is run through [`Redactor`] (when one is attached) and then
//! XML-escaped, attribute or CDATA context as appropriate.

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

use super::xml_escape;
use crate::redaction::Redactor;
use crate::report::{Report, Reporter, TestResult, TestStatus};

/// Maximum CDATA body size emitted per `<failure>` element. JUnit
/// parsers (Jenkins, dorny/test-reporter, surefire) cap per-element
/// buffers far below the full report size; 64 KB is the conventional
/// safe ceiling and matches how `cargo nextest` truncates failure
/// detail. Anything longer is suffixed with a one-line marker so
/// readers know the body was trimmed.
const FAILURE_BODY_LIMIT: usize = 64 * 1024;

/// Marker appended after a truncated `<failure>` body.
const TRUNCATION_MARKER: &str = "\n... [truncated, see full report]";

/// JUnit XML reporter; see module docs.
///
/// Accepts an optional [`Redactor`] via [`JunitReporter::with_redactor`].
/// When set, user-supplied strings (test name, failure message, diff
/// body) are run through the redactor before XML escaping. Prefer
/// [`Report::redacted`] at the dispatch site when redacting many
/// reporters at once.
#[derive(Default)]
pub struct JunitReporter {
    redactor: Option<Arc<Redactor>>,
}

impl JunitReporter {
    /// 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
    }

    fn redact<'a>(&self, s: &'a str) -> std::borrow::Cow<'a, str> {
        match &self.redactor {
            Some(r) => r.redact_value(s),
            None => std::borrow::Cow::Borrowed(s),
        }
    }
}

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

    fn render(&self, report: &Report, out: &mut dyn Write) -> std::io::Result<()> {
        let suites = group_by_file(&report.tests);
        // Root counts derive from the testcases actually rendered below,
        // not from `report.summary`, so the document stays internally
        // consistent even when a hand-built or deserialized summary
        // disagrees with the test vec (WOR-1317).
        let total = report.tests.len();
        let failures = count_failures(report.tests.iter());
        let skipped = count_skipped(report.tests.iter());
        let suite_time_s = report.summary.duration_ms as f64 / 1000.0;
        writeln!(out, r#"<?xml version="1.0" encoding="UTF-8"?>"#)?;
        writeln!(
            out,
            r#"<testsuites tests="{total}" failures="{failures}" errors="0" skipped="{skipped}" time="{suite_time_s:.3}">"#
        )?;
        for (suite_name, tests) in &suites {
            self.render_suite(suite_name, tests, out)?;
        }
        writeln!(out, r#"</testsuites>"#)?;
        Ok(())
    }
}

impl JunitReporter {
    fn render_suite(
        &self,
        suite_name: &str,
        tests: &[&TestResult],
        out: &mut dyn Write,
    ) -> std::io::Result<()> {
        let suite_total = tests.len();
        let suite_failures = count_failures(tests.iter().copied());
        let suite_skipped = count_skipped(tests.iter().copied());
        let suite_time_ms: u64 = tests.iter().map(|t| t.duration_ms).sum();
        let suite_time_s = suite_time_ms as f64 / 1000.0;
        let name_attr = xml_escape(suite_name);
        writeln!(
            out,
            r#"  <testsuite name="{name_attr}" tests="{suite_total}" failures="{suite_failures}" errors="0" skipped="{suite_skipped}" time="{suite_time_s:.3}">"#
        )?;
        for test in tests {
            self.render_testcase(suite_name, test, out)?;
        }
        writeln!(out, r#"  </testsuite>"#)?;
        Ok(())
    }

    fn render_testcase(
        &self,
        classname: &str,
        test: &TestResult,
        out: &mut dyn Write,
    ) -> std::io::Result<()> {
        let time_s = test.duration_ms as f64 / 1000.0;
        let name_red = self.redact(&test.name);
        let name = xml_escape(&name_red);
        let classname_attr = xml_escape(classname);
        writeln!(
            out,
            r#"    <testcase name="{name}" classname="{classname_attr}" time="{time_s:.3}">"#
        )?;
        // JUnit has no native cache-hit attribute; the convention is
        // a <property> child block.
        if test.cache_hit {
            writeln!(
                out,
                r#"      <properties><property name="cache_hit" value="true"/></properties>"#
            )?;
        }
        match test.status {
            TestStatus::Fail => self.render_failure(test, out)?,
            TestStatus::Skip => writeln!(out, r#"      <skipped/>"#)?,
            TestStatus::Inconclusive => {
                writeln!(out, r#"      <skipped message="inconclusive"/>"#)?
            }
            TestStatus::Pass => {}
        }
        writeln!(out, r#"    </testcase>"#)?;
        Ok(())
    }

    fn render_failure(&self, test: &TestResult, out: &mut dyn Write) -> std::io::Result<()> {
        let Some(failure) = &test.failure else {
            writeln!(
                out,
                r#"      <failure message="test failed" type="failure"/>"#
            )?;
            return Ok(());
        };
        let msg_red = self.redact(&failure.message);
        let msg = xml_escape(&msg_red);
        let type_attr = failure_type(test);
        let raw_body = match &failure.diff {
            Some(d) => self.redact(d).into_owned(),
            None => String::new(),
        };
        if raw_body.is_empty() {
            writeln!(
                out,
                r#"      <failure message="{msg}" type="{type_attr}"/>"#
            )?;
            return Ok(());
        }
        let body = truncate_failure_body(&raw_body);
        // CDATA body. JUnit parsers accept `]]>` only as the section
        // terminator, so we split any embedded sequence to keep the
        // body safe. This is a rare collision but cheap to defend.
        let safe_body = body.replace("]]>", "]]]]><![CDATA[>");
        writeln!(
            out,
            r#"      <failure message="{msg}" type="{type_attr}"><![CDATA[{safe_body}]]></failure>"#
        )?;
        Ok(())
    }
}

/// Count the testcases that render a `<failure>` element. Shared by the
/// root and per-suite attribute paths so both always agree with the
/// elements inside them (WOR-1317).
fn count_failures<'a>(tests: impl Iterator<Item = &'a TestResult>) -> usize {
    tests.filter(|t| t.status == TestStatus::Fail).count()
}

/// Count the testcases that render a `<skipped>` element. Both `Skip`
/// and `Inconclusive` emit one (see `render_testcase`), so both feed the
/// `skipped` attribute; deriving the attribute and the element from the
/// same predicate is what keeps the document internally consistent.
fn count_skipped<'a>(tests: impl Iterator<Item = &'a TestResult>) -> usize {
    tests
        .filter(|t| matches!(t.status, TestStatus::Skip | TestStatus::Inconclusive))
        .count()
}

/// Group tests by their `file` field, preserving the first-seen order
/// of each file so the rendered output is deterministic. Tests without
/// a `file` collapse into a single synthetic `mcptest` suite so older
/// callers (and the canonical samples) still produce valid output.
fn group_by_file<'a>(tests: &'a [TestResult]) -> Vec<(String, Vec<&'a TestResult>)> {
    let mut order: Vec<String> = Vec::new();
    let mut buckets: BTreeMap<String, Vec<&'a TestResult>> = BTreeMap::new();
    for t in tests {
        let key = t.file.clone().unwrap_or_else(|| "mcptest".to_string());
        if !buckets.contains_key(&key) {
            order.push(key.clone());
        }
        buckets.entry(key).or_default().push(t);
    }
    order
        .into_iter()
        .map(|k| {
            let v = buckets.remove(&k).unwrap_or_default();
            (k, v)
        })
        .collect()
}

/// Truncate a failure body to [`FAILURE_BODY_LIMIT`] bytes, appending
/// a marker so readers can tell the body was trimmed. Truncation is
/// done on a char boundary so we never emit invalid UTF-8 into the
/// XML.
fn truncate_failure_body(body: &str) -> String {
    if body.len() <= FAILURE_BODY_LIMIT {
        return body.to_string();
    }
    let mut cutoff = FAILURE_BODY_LIMIT;
    while cutoff > 0 && !body.is_char_boundary(cutoff) {
        cutoff -= 1;
    }
    let mut truncated = String::with_capacity(cutoff + TRUNCATION_MARKER.len());
    truncated.push_str(&body[..cutoff]);
    truncated.push_str(TRUNCATION_MARKER);
    truncated
}

/// Map a failed test to a `type` attribute. Compliance rule failures
/// surface their rule id (`PROTO-001`, `EDGE-005`, etc.) so CI
/// dashboards can group failures by rule; plain assertion failures
/// fall back to a generic `assertion` type.
fn failure_type(test: &TestResult) -> String {
    match &test.rule_id {
        Some(id) => xml_escape(id),
        None => "assertion".to_string(),
    }
}

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

    #[test]
    fn junit_reporter_emits_correct_counts() {
        let r = Report::sample();
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains(
            r#"<testsuites tests="3" failures="1" errors="0" skipped="0" time="0.250">"#
        ));
        assert!(s.contains(r#"<testcase name="search returns at least one hit""#));
        assert!(s.contains(r#"<failure message="expected error but tool returned ok"#));
    }

    #[test]
    fn junit_reporter_escapes_xml_entities() {
        let mut r = Report::sample();
        r.tests[0].name = "rejects <script>alert(\"x\")</script>".into();
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("&lt;script&gt;"));
        assert!(s.contains("&quot;x&quot;"));
        assert!(!s.contains("<script>"));
    }

    #[test]
    fn junit_reporter_name_is_stable() {
        assert_eq!(JunitReporter::default().name(), "junit");
    }

    #[test]
    fn junit_reporter_emits_time_on_testsuite_and_root() {
        let r = Report::sample();
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        // Root totals 250 ms. Per-suite times add up to the same
        // wall-clock since the runner reports a per-test duration that
        // sums to the suite total in the canonical sample.
        assert!(s.contains(
            r#"<testsuites tests="3" failures="1" errors="0" skipped="0" time="0.250">"#
        ));
        // sample() places each test in a distinct YAML file, so we get
        // three single-test suites.
        assert!(s.contains(r#"<testsuite name="tests/search.yml" tests="1""#));
        assert!(s.contains(r#"<testsuite name="tests/list_issues.yml" tests="1""#));
        assert!(s.contains(r#"<testsuite name="tests/create_issue.yml" tests="1""#));
    }

    #[test]
    fn junit_reporter_emits_cache_hit_property_for_cached_tests() {
        let r = Report::sample();
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains(r#"<property name="cache_hit" value="true"/>"#));
        // The cache_hit property should appear once (only the cached test).
        let count = s.matches("cache_hit").count();
        assert_eq!(count, 1, "expected one cache_hit property, got {count}");
    }

    #[test]
    fn junit_reporter_redacts_token_shaped_test_name() {
        let mut r = Report::sample();
        r.tests[0].name = "ghp_abcdefghijklmnop1234".into();
        let reporter = JunitReporter::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 junit_reporter_emits_classname_on_testcase() {
        let r = Report::sample();
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        // classname mirrors the source file path so CI dashboards can
        // group failures by file.
        assert!(s.contains(r#"classname="tests/search.yml""#));
        assert!(s.contains(r#"classname="tests/list_issues.yml""#));
        assert!(s.contains(r#"classname="tests/create_issue.yml""#));
    }

    #[test]
    fn junit_reporter_emits_one_suite_per_file() {
        // Two tests sharing a file collapse into one suite with
        // tests=2 and the per-suite time aggregates correctly.
        let mut r = Report::sample();
        r.tests[1].file = Some("tests/search.yml".into());
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains(
            r#"<testsuite name="tests/search.yml" tests="2" failures="0" errors="0" skipped="0" time="0.081">"#
        ));
    }

    #[test]
    fn junit_reporter_wraps_failure_diff_in_cdata() {
        let r = Report::sample();
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        // The diff body has `<` and `>` characters in the sample, so
        // CDATA wrapping is the safest path. Without it we'd have to
        // double-escape diff content that contains `<`.
        assert!(s.contains("<![CDATA["));
        assert!(s.contains("]]>"));
        assert!(s.contains("expected: error code -32602"));
    }

    #[test]
    fn junit_reporter_truncates_oversized_failure_body() {
        let mut r = Report::sample();
        // 200 KB diff body, well past the 64 KB cap.
        let big = "x".repeat(200 * 1024);
        r.tests[2].failure = Some(TestFailure {
            message: "huge failure".into(),
            diff: Some(big),
            network_diagnostic: None,
        });
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("[truncated, see full report]"));
        // Body inside the CDATA section must be no larger than the
        // cap plus the truncation marker. A loose check is fine.
        let cdata_start = s.find("<![CDATA[").unwrap();
        let cdata_end = s[cdata_start..].find("]]>").unwrap() + cdata_start;
        let body_len = cdata_end - cdata_start - "<![CDATA[".len();
        assert!(
            body_len <= FAILURE_BODY_LIMIT + TRUNCATION_MARKER.len() + 16,
            "body length {body_len} exceeds cap"
        );
    }

    #[test]
    fn junit_reporter_emits_failure_type_for_compliance_rules() {
        let r = Report::sample_full();
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        // sample_full has PROTO-001, EDGE-005, RES-001 as failed rule ids.
        assert!(s.contains(r#"type="PROTO-001""#));
        assert!(s.contains(r#"type="EDGE-005""#));
        assert!(s.contains(r#"type="RES-001""#));
    }

    #[test]
    fn junit_reporter_renders_cache_hits_as_passes_not_skipped() {
        let r = Report::sample();
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        // sample() carries one cache hit and zero skipped tests. The
        // root <testsuites> and the cached test's suite must show
        // skipped="0", and the cached <testcase> must not contain a
        // <skipped/> element.
        assert!(s.contains(r#"skipped="0""#));
        // The cached testcase block lives inside tests/list_issues.yml.
        let suite_start = s.find(r#"name="tests/list_issues.yml""#).unwrap();
        let suite_end = s[suite_start..].find("</testsuite>").unwrap() + suite_start;
        let suite_block = &s[suite_start..suite_end];
        assert!(!suite_block.contains("<skipped"));
    }

    #[test]
    fn junit_reporter_handles_skip_with_empty_element() {
        let mut r = Report::sample();
        r.tests[1].status = TestStatus::Skip;
        r.tests[1].cache_hit = false;
        r.summary.skipped = 1;
        r.summary.passed = 1;
        let mut buf = Vec::new();
        JunitReporter::default().render(&r, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains(r#"<skipped/>"#));
        assert!(s.contains(r#"skipped="1""#));
    }
}
