//! JUnit XML reporter structural validation tests.
//!
//! Trade-off note: the Rust ecosystem has no maintained offline XSD
//! validator we can wire into CI without dragging in a libxml2 build.
//! Instead we vendor a JUnit XSD subset under
//! `tests/fixtures/junit.xsd` as executable documentation of the
//! shape we commit to, and use `quick-xml` here to parse the rendered
//! output and assert the same constraints structurally:
//!
//! * `<?xml version="1.0" encoding="UTF-8"?>` prolog present.
//! * Root `<testsuites>` carries `tests`, `failures`, `errors`,
//!   `skipped`, and `time` attributes.
//! * Each `<testsuite>` carries those same attributes plus `name`.
//! * Each `<testcase>` carries `name`, `classname`, and `time`.
//! * `time` is a decimal with exactly three fractional digits
//!   (seconds-with-millisecond precision is the JUnit convention).
//! * Per-suite `tests`/`failures`/`skipped` counts equal the actual
//!   number of contained testcases / `<failure>` / `<skipped>`
//!   elements.
//! * `<failure>` carries a `type` attribute and a CDATA body
//!   (when there is diff content to surface).
//! * Cache hits render as `<property name="cache_hit" value="true"/>`
//!   children, not `<skipped/>`.
//!
//! When we tighten or relax the reporter's structural surface, update
//! both the XSD fixture and the assertions here in the same commit.

use mcptest_core::report::reporters::JunitReporter;
use mcptest_core::report::{Report, Reporter, TestFailure};
use quick_xml::events::Event;
use quick_xml::Reader;
use std::collections::HashMap;

#[derive(Default, Debug)]
#[allow(dead_code)]
struct ParsedSuite {
    name: String,
    declared_tests: usize,
    declared_failures: usize,
    declared_errors: usize,
    declared_skipped: usize,
    declared_time: String,
    actual_tests: usize,
    actual_failures: usize,
    actual_skipped: usize,
    testcases: Vec<ParsedCase>,
}

#[derive(Default, Debug)]
struct ParsedCase {
    name: String,
    classname: String,
    time: String,
    failure: Option<ParsedFailure>,
    skipped: bool,
    properties: HashMap<String, String>,
}

#[derive(Default, Debug)]
#[allow(dead_code)]
struct ParsedFailure {
    message: String,
    failure_type: String,
    body: String,
}

#[derive(Default, Debug)]
struct ParsedReport {
    declared_tests: usize,
    declared_failures: usize,
    declared_errors: usize,
    declared_skipped: usize,
    declared_time: String,
    suites: Vec<ParsedSuite>,
    has_xml_prolog: bool,
}

fn render_junit(report: &Report) -> String {
    let mut buf = Vec::new();
    JunitReporter::default()
        .render(report, &mut buf)
        .expect("JUnit render");
    String::from_utf8(buf).expect("UTF-8")
}

fn attr(e: &quick_xml::events::BytesStart, key: &str) -> Option<String> {
    for a in e.attributes().flatten() {
        if a.key.as_ref() == key.as_bytes() {
            let v = a.unescape_value().ok()?.into_owned();
            return Some(v);
        }
    }
    None
}

// Test-only XML parser: one cohesive quick-xml event loop that walks the
// rendered JUnit document. The AGENTS function-length rule targets production
// code; this scaffolding stays in one place rather than being split into
// artificial sub-parsers, so the lint is allowed here.
#[allow(clippy::too_many_lines)]
fn parse(xml: &str) -> ParsedReport {
    let mut reader = Reader::from_str(xml);
    reader.config_mut().trim_text(true);
    let mut buf = Vec::new();
    let mut out = ParsedReport {
        has_xml_prolog: xml.starts_with("<?xml"),
        ..Default::default()
    };
    let mut current_suite: Option<ParsedSuite> = None;
    let mut current_case: Option<ParsedCase> = None;
    let mut in_failure: bool = false;
    let mut failure_body_buf: String = String::new();
    loop {
        let ev = reader.read_event_into(&mut buf).expect("xml parses");
        match ev {
            Event::Start(e) => {
                let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
                match tag.as_str() {
                    "testsuites" => {
                        out.declared_tests = attr(&e, "tests")
                            .and_then(|v| v.parse().ok())
                            .unwrap_or_default();
                        out.declared_failures = attr(&e, "failures")
                            .and_then(|v| v.parse().ok())
                            .unwrap_or_default();
                        out.declared_errors = attr(&e, "errors")
                            .and_then(|v| v.parse().ok())
                            .unwrap_or_default();
                        out.declared_skipped = attr(&e, "skipped")
                            .and_then(|v| v.parse().ok())
                            .unwrap_or_default();
                        out.declared_time = attr(&e, "time").unwrap_or_default();
                    }
                    "testsuite" => {
                        current_suite = Some(ParsedSuite {
                            name: attr(&e, "name").unwrap_or_default(),
                            declared_tests: attr(&e, "tests")
                                .and_then(|v| v.parse().ok())
                                .unwrap_or_default(),
                            declared_failures: attr(&e, "failures")
                                .and_then(|v| v.parse().ok())
                                .unwrap_or_default(),
                            declared_errors: attr(&e, "errors")
                                .and_then(|v| v.parse().ok())
                                .unwrap_or_default(),
                            declared_skipped: attr(&e, "skipped")
                                .and_then(|v| v.parse().ok())
                                .unwrap_or_default(),
                            declared_time: attr(&e, "time").unwrap_or_default(),
                            ..Default::default()
                        });
                    }
                    "testcase" => {
                        current_case = Some(ParsedCase {
                            name: attr(&e, "name").unwrap_or_default(),
                            classname: attr(&e, "classname").unwrap_or_default(),
                            time: attr(&e, "time").unwrap_or_default(),
                            ..Default::default()
                        });
                    }
                    "failure" => {
                        if let Some(case) = current_case.as_mut() {
                            case.failure = Some(ParsedFailure {
                                message: attr(&e, "message").unwrap_or_default(),
                                failure_type: attr(&e, "type").unwrap_or_default(),
                                body: String::new(),
                            });
                            in_failure = true;
                            failure_body_buf.clear();
                        }
                    }
                    "property" => {
                        if let Some(case) = current_case.as_mut() {
                            let name = attr(&e, "name").unwrap_or_default();
                            let value = attr(&e, "value").unwrap_or_default();
                            case.properties.insert(name, value);
                        }
                    }
                    _ => {}
                }
            }
            Event::Empty(e) => {
                let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
                match tag.as_str() {
                    "skipped" => {
                        if let Some(case) = current_case.as_mut() {
                            case.skipped = true;
                            if let Some(suite) = current_suite.as_mut() {
                                suite.actual_skipped += 1;
                            }
                        }
                    }
                    "failure" => {
                        if let Some(case) = current_case.as_mut() {
                            case.failure = Some(ParsedFailure {
                                message: attr(&e, "message").unwrap_or_default(),
                                failure_type: attr(&e, "type").unwrap_or_default(),
                                body: String::new(),
                            });
                            if let Some(suite) = current_suite.as_mut() {
                                suite.actual_failures += 1;
                            }
                        }
                    }
                    "property" => {
                        if let Some(case) = current_case.as_mut() {
                            let name = attr(&e, "name").unwrap_or_default();
                            let value = attr(&e, "value").unwrap_or_default();
                            case.properties.insert(name, value);
                        }
                    }
                    _ => {}
                }
            }
            Event::CData(t) if in_failure => {
                failure_body_buf.push_str(&String::from_utf8_lossy(&t));
            }
            Event::Text(t) if in_failure => {
                failure_body_buf.push_str(&String::from_utf8_lossy(&t));
            }
            Event::End(e) => {
                let tag = String::from_utf8_lossy(e.name().as_ref()).to_string();
                match tag.as_str() {
                    "failure" => {
                        if let (Some(case), true) = (current_case.as_mut(), in_failure) {
                            if let Some(f) = case.failure.as_mut() {
                                f.body = failure_body_buf.clone();
                            }
                            if let Some(suite) = current_suite.as_mut() {
                                suite.actual_failures += 1;
                            }
                            in_failure = false;
                        }
                    }
                    "testcase" => {
                        if let Some(case) = current_case.take() {
                            if let Some(suite) = current_suite.as_mut() {
                                suite.actual_tests += 1;
                                suite.testcases.push(case);
                            }
                        }
                    }
                    "testsuite" => {
                        if let Some(suite) = current_suite.take() {
                            out.suites.push(suite);
                        }
                    }
                    _ => {}
                }
            }
            Event::Eof => break,
            _ => {}
        }
        buf.clear();
    }
    out
}

fn parse_time(s: &str) -> f64 {
    s.parse::<f64>().expect("time parses")
}

fn assert_time_three_decimals(s: &str) {
    let dot = s
        .find('.')
        .unwrap_or_else(|| panic!("time `{s}` missing decimal point"));
    let frac = &s[dot + 1..];
    assert_eq!(
        frac.len(),
        3,
        "time `{s}` should carry exactly three fractional digits"
    );
}

#[test]
fn rendered_junit_for_sample_report_has_required_structure() {
    let xml = render_junit(&Report::sample());
    let parsed = parse(&xml);
    assert!(parsed.has_xml_prolog, "expected the XML prolog");
    assert_eq!(parsed.declared_tests, 3);
    assert_eq!(parsed.declared_failures, 1);
    assert_eq!(parsed.declared_errors, 0);
    assert_eq!(parsed.declared_skipped, 0);
    assert!(parse_time(&parsed.declared_time) > 0.0);
    // sample() spreads three tests across three files => three suites.
    assert_eq!(parsed.suites.len(), 3);
    for suite in &parsed.suites {
        assert!(!suite.name.is_empty(), "suite name is required");
        assert_eq!(suite.actual_tests, suite.declared_tests);
        assert_eq!(suite.actual_failures, suite.declared_failures);
        assert_eq!(suite.actual_skipped, suite.declared_skipped);
        for case in &suite.testcases {
            assert!(!case.name.is_empty(), "testcase name is required");
            assert!(!case.classname.is_empty(), "testcase classname is required");
            assert_time_three_decimals(&case.time);
        }
    }
}

#[test]
fn rendered_junit_for_sample_full_report_has_required_structure() {
    let xml = render_junit(&Report::sample_full());
    let parsed = parse(&xml);
    assert_eq!(parsed.declared_tests, 4);
    assert_eq!(parsed.declared_failures, 3);
    assert_eq!(parsed.declared_errors, 0);
    assert!(!parsed.suites.is_empty());
    let failure_types: Vec<&str> = parsed
        .suites
        .iter()
        .flat_map(|s| s.testcases.iter())
        .filter_map(|c| c.failure.as_ref())
        .map(|f| f.failure_type.as_str())
        .collect();
    // The compliance fixture surfaces every failed rule id as the
    // failure `type`, which CI dashboards group by.
    assert!(failure_types.contains(&"PROTO-001"));
    assert!(failure_types.contains(&"EDGE-005"));
    assert!(failure_types.contains(&"RES-001"));
}

#[test]
fn rendered_junit_for_all_pass_report_has_required_structure() {
    let xml = render_junit(&Report::sample_all_pass());
    let parsed = parse(&xml);
    assert_eq!(parsed.declared_tests, 1);
    assert_eq!(parsed.declared_failures, 0);
    assert_eq!(parsed.declared_errors, 0);
    assert_eq!(parsed.declared_skipped, 0);
    assert_eq!(parsed.suites.len(), 1);
    let suite = &parsed.suites[0];
    assert_eq!(suite.name, "tests/smoke.yml");
    assert_eq!(suite.actual_tests, 1);
    assert_eq!(suite.actual_failures, 0);
}

#[test]
fn rendered_junit_cache_hit_renders_as_property_not_skipped() {
    let xml = render_junit(&Report::sample());
    let parsed = parse(&xml);
    let cached: Vec<&ParsedCase> = parsed
        .suites
        .iter()
        .flat_map(|s| s.testcases.iter())
        .filter(|c| c.properties.get("cache_hit").map(String::as_str) == Some("true"))
        .collect();
    assert_eq!(cached.len(), 1, "expected exactly one cache-hit testcase");
    assert!(!cached[0].skipped, "cache hits must not render as skipped");
    // Belt and suspenders: the global skipped counter should be zero.
    assert_eq!(parsed.declared_skipped, 0);
}

#[test]
fn rendered_junit_truncates_failure_bodies_to_64k() {
    let mut r = Report::sample();
    let big = "y".repeat(200 * 1024);
    r.tests[2].failure = Some(TestFailure {
        message: "huge failure".into(),
        diff: Some(big),
        network_diagnostic: None,
    });
    let xml = render_junit(&r);
    let parsed = parse(&xml);
    let failure = parsed
        .suites
        .iter()
        .flat_map(|s| s.testcases.iter())
        .find_map(|c| c.failure.as_ref())
        .expect("failure present");
    assert!(
        failure.body.contains("[truncated"),
        "expected truncation marker in failure body"
    );
    // 64 KB + small marker is the contract. 70 KB is plenty of slack.
    assert!(
        failure.body.len() <= 70 * 1024,
        "failure body should be capped, got {} bytes",
        failure.body.len()
    );
}

#[test]
fn rendered_junit_suite_counts_match_contained_elements() {
    // A two-test, one-failure suite stresses the per-suite aggregation
    // path: the declared `tests`/`failures` attributes on the suite
    // must equal what's actually inside it.
    let mut r = Report::sample();
    r.tests[1].file = Some("tests/search.yml".into());
    let xml = render_junit(&r);
    let parsed = parse(&xml);
    let combined = parsed
        .suites
        .iter()
        .find(|s| s.name == "tests/search.yml")
        .expect("combined suite present");
    assert_eq!(combined.declared_tests, 2);
    assert_eq!(combined.actual_tests, 2);
    assert_eq!(combined.declared_failures, 0);
    assert_eq!(combined.actual_failures, 0);
}

#[test]
fn rendered_junit_xsd_fixture_is_documented_and_present() {
    // The XSD fixture itself is a documentation artifact (the Rust
    // ecosystem lacks an offline XSD validator). Assert it exists and
    // mentions the elements we depend on so a future cleanup pass
    // doesn't quietly delete it.
    let xsd_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join("junit.xsd");
    let body = std::fs::read_to_string(&xsd_path).expect("vendored JUnit XSD exists");
    for element in &["testsuites", "testsuite", "testcase", "failure", "skipped"] {
        assert!(
            body.contains(&format!("name=\"{element}\"")),
            "XSD fixture missing element {element}"
        );
    }
}

#[test]
fn rendered_junit_failure_body_uses_cdata_section() {
    let xml = render_junit(&Report::sample());
    // CDATA delimiter must appear immediately inside <failure ...> so
    // that diff bodies containing `<` and `&` do not need to be
    // double-escaped by the reporter.
    let needle = "<![CDATA[";
    let pos = xml.find(needle).expect("CDATA section present");
    let preceding = &xml[..pos];
    assert!(
        preceding.ends_with("type=\"assertion\">") || preceding.contains("<failure"),
        "CDATA must live inside a <failure> element"
    );
}
