//! JUnit XML merging utilities.
//!
//! This module provides functions to merge multiple JUnit XML files into one.
//! Used to combine results from parallel test execution across multiple sandboxes.

use std::collections::{HashMap, HashSet};
use std::io::Cursor;
use std::path::Path;
use std::sync::{Arc, Mutex};

use quick_xml::events::{BytesDecl, BytesEnd, BytesStart, BytesText, Event};
use quick_xml::{Reader, Writer};
use tracing::{info, warn};

/// Tracks the outcome of a single test ID across execution attempts.
///
/// Flakiness is detected across testsuites: failed in one batch, passed in another.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TestStatus {
    Passed,
    Failed,
    Flaky, // Passed after failing (or vice versa)
}

/// Unique identifier for a test case.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct TestId {
    classname: Option<String>,
    name: String,
}

impl TestId {
    fn new(classname: Option<String>, name: String) -> Self {
        Self { classname, name }
    }
}

/// Parsed testsuite element from JUnit XML.
#[derive(Debug, Clone)]
pub struct TestsuiteXml {
    pub name: String,
    pub tests: i32,
    pub failures: i32,
    pub errors: i32,
    pub skipped: i32,
    pub time: f64,
    pub timestamp: Option<String>,
    pub hostname: Option<String>,
    pub testcases: Vec<TestcaseXml>,
}

/// Parsed testcase element from JUnit XML.
#[derive(Debug, Clone)]
pub struct TestcaseXml {
    pub name: String,
    pub classname: Option<String>,
    pub time: f64,
    pub failure: Option<FailureXml>,
    pub error: Option<FailureXml>,
}

/// Parsed failure/error element from JUnit XML.
#[derive(Debug, Clone)]
pub struct FailureXml {
    pub message: Option<String>,
    pub content: String,
}

/// Accumulated JUnit results from all batches.
///
/// Each test ID maps 1:1 to a testcase. Flakiness is detected across
/// testsuites (batches/retries): a test that fails in one batch and
/// passes in another is marked flaky.
#[derive(Debug)]
pub struct MasterJunitReport {
    /// Parsed testsuites from each batch
    testsuites: Vec<TestsuiteXml>,
    /// Test outcomes by test ID (for flaky detection and early stopping)
    test_outcomes: HashMap<TestId, TestStatus>,
    /// Total number of unique test IDs expected (for early stopping)
    total_expected: usize,
    /// Format string for constructing test IDs from JUnit XML attributes.
    test_id_format: String,
    /// Set of discovered test IDs for validation
    known_test_ids: HashSet<String>,
    /// Tracks unrecognized test IDs that have already been warned about
    warned_unknown_ids: HashSet<String>,
}

impl MasterJunitReport {
    /// Creates a new master report expecting the given number of unique test IDs.
    pub fn new(
        total_expected: usize,
        test_id_format: &str,
        known_test_ids: HashSet<String>,
    ) -> Self {
        Self {
            testsuites: Vec::new(),
            test_outcomes: HashMap::new(),
            total_expected,
            test_id_format: test_id_format.to_string(),
            known_test_ids,
            warned_unknown_ids: HashSet::new(),
        }
    }

    /// Adds JUnit XML content from a batch.
    ///
    /// Each testsuite represents one batch execution. Test outcomes are
    /// tracked per test ID for flaky detection across retries.
    pub fn add_junit_xml(&mut self, xml_content: &str) {
        let parsed_testsuites = parse_all_testsuites_xml(xml_content);

        if parsed_testsuites.is_empty() {
            warn!(
                "Failed to parse JUnit XML ({} bytes), content preview: {:?}",
                xml_content.len(),
                &xml_content[..xml_content.len().min(200)]
            );
            return;
        }

        let before_count = self.test_outcomes.len();
        let mut total_testcases = 0;

        for testsuite in parsed_testsuites {
            total_testcases += testsuite.testcases.len();

            // Group testcases by test ID within this testsuite.
            // A test ID fails if ANY of its testcases failed.
            let mut suite_outcomes: HashMap<TestId, bool> = HashMap::new();
            for testcase in &testsuite.testcases {
                let test_id = TestId::new(testcase.classname.clone(), testcase.name.clone());
                let failed = testcase.failure.is_some() || testcase.error.is_some();
                let entry = suite_outcomes.entry(test_id).or_insert(false);
                if failed {
                    *entry = true;
                }
            }

            // Validate test IDs against discovered IDs
            if !self.known_test_ids.is_empty() {
                for test_id in suite_outcomes.keys() {
                    let formatted_id = crate::config::format_test_id(
                        &self.test_id_format,
                        &test_id.name,
                        test_id.classname.as_deref(),
                    );
                    if !self.known_test_ids.contains(&formatted_id)
                        && self.warned_unknown_ids.insert(formatted_id.clone())
                    {
                        let mut candidates: Vec<&String> = self.known_test_ids.iter().collect();
                        candidates.sort_by_cached_key(|known| {
                            edit_distance::edit_distance(&formatted_id, known)
                        });
                        let top3: Vec<String> = candidates
                            .iter()
                            .take(3)
                            .map(|id| format!("  '{}'", id))
                            .collect();
                        warn!(
                            "Unrecognized test ID from JUnit XML: '{}'. Closest discovered IDs:\n{}\nHint: Ensure `sandbox_project_root` is set correctly, tests are run from the project root, and reporting hooks (e.g. conftest fixtures that set JUnit test IDs) are configured correctly.",
                            formatted_id,
                            top3.join("\n")
                        );
                    }
                }
            }

            // Update global outcomes with this suite's per-ID results.
            for (test_id, failed) in suite_outcomes {
                self.update_test_outcome(test_id, failed);
            }

            self.testsuites.push(testsuite);
        }

        let after_count = self.test_outcomes.len();
        let new_tests = after_count - before_count;
        info!(
            "Added {} testsuites with {} testcases total, {} new unique test IDs (total: {})",
            self.testsuites.len(),
            total_testcases,
            new_tests,
            after_count
        );
    }

    /// Updates the test outcome with flaky detection.
    ///
    /// Called once per test ID per testsuite with the aggregate result
    /// for that ID in that suite.
    fn update_test_outcome(&mut self, test_id: TestId, failed: bool) {
        let current = self.test_outcomes.get(&test_id).cloned();
        let new_status = match (current, failed) {
            (None, false) => TestStatus::Passed,
            (None, true) => TestStatus::Failed,
            (Some(TestStatus::Failed), false) => TestStatus::Flaky,
            (Some(TestStatus::Passed), true) => TestStatus::Flaky,
            (Some(status), _) => status,
        };
        self.test_outcomes.insert(test_id, new_status);
    }

    /// Returns the number of unique test IDs that passed (including flaky).
    pub fn passed_count(&self) -> usize {
        self.test_outcomes
            .values()
            .filter(|s| **s == TestStatus::Passed || **s == TestStatus::Flaky)
            .count()
    }

    /// Returns the number of unique test IDs that failed (not flaky).
    pub fn failed_count(&self) -> usize {
        self.test_outcomes
            .values()
            .filter(|s| **s == TestStatus::Failed)
            .count()
    }

    /// Returns the number of flaky test IDs.
    pub fn flaky_count(&self) -> usize {
        self.test_outcomes
            .values()
            .filter(|s| **s == TestStatus::Flaky)
            .count()
    }

    /// Returns the total number of unique test IDs seen.
    pub fn total_count(&self) -> usize {
        self.test_outcomes.len()
    }

    /// Returns true if all expected test IDs have passed.
    pub fn all_passed(&self) -> bool {
        self.passed_count() >= self.total_expected
    }

    /// Returns true if the test with the given full ID has already passed or is flaky.
    ///
    /// Reconstructs the full test ID from JUnit XML classname/name attributes
    /// using the configured `test_id_format` before comparing.
    pub fn has_test_passed(&self, test_id: &str) -> bool {
        self.test_outcomes.iter().any(|(id, status)| {
            let full_id = crate::config::format_test_id(
                &self.test_id_format,
                &id.name,
                id.classname.as_deref(),
            );
            full_id == test_id && (*status == TestStatus::Passed || *status == TestStatus::Flaky)
        })
    }

    /// Total number of raw testcases across all testsuites.
    pub fn testcase_count(&self) -> usize {
        self.testsuites.iter().map(|s| s.testcases.len()).sum()
    }

    /// Writes the accumulated JUnit XML to a file using quick-xml Writer.
    pub fn write_to_file(&self, output_path: &Path) -> std::io::Result<()> {
        if let Some(parent) = output_path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        let total_tests: i32 = self.testsuites.iter().map(|s| s.tests).sum();
        let total_failures: i32 = self.testsuites.iter().map(|s| s.failures).sum();
        let total_errors: i32 = self.testsuites.iter().map(|s| s.errors).sum();
        let total_time: f64 = self.testsuites.iter().map(|s| s.time).sum();

        let actual_testcases: usize = self.testsuites.iter().map(|s| s.testcases.len()).sum();
        let unique_ids = self.test_outcomes.len();

        info!(
            "Writing JUnit XML: {} testsuites, {} testcases, {} unique test IDs",
            self.testsuites.len(),
            actual_testcases,
            unique_ids
        );

        let output = write_testsuites_xml(
            &self.testsuites,
            total_tests,
            total_failures,
            total_errors,
            total_time,
        );

        std::fs::write(output_path, output)?;
        info!("Wrote merged JUnit XML to {}", output_path.display());

        Ok(())
    }

    /// Returns summary counts: (passed, failed, flaky) — all per test ID.
    pub fn summary(&self) -> (usize, usize, usize) {
        let passed = self
            .test_outcomes
            .values()
            .filter(|s| **s == TestStatus::Passed)
            .count();
        let failed = self.failed_count();
        let flaky = self.flaky_count();
        (passed, failed, flaky)
    }
}

/// Extracts a string attribute, decoding XML entities (`&gt;` -> `>`).
fn get_attr(e: &BytesStart, name: &[u8]) -> Option<String> {
    e.attributes()
        .flatten()
        .find(|a| a.key.as_ref() == name)
        .and_then(|a| a.unescape_value().ok().map(|s| s.into_owned()))
}

/// Helper to extract an i32 attribute with default 0.
fn get_attr_i32(e: &BytesStart, name: &[u8]) -> i32 {
    get_attr(e, name).and_then(|s| s.parse().ok()).unwrap_or(0)
}

/// Helper to extract an f64 attribute with default 0.0.
fn get_attr_f64(e: &BytesStart, name: &[u8]) -> f64 {
    get_attr(e, name)
        .and_then(|s| s.parse().ok())
        .unwrap_or(0.0)
}

/// Parses a JUnit XML string into a TestsuiteXml structure using quick-xml.
/// Parse all testsuites from JUnit XML (handles multiple testsuites in one file).
pub fn parse_all_testsuites_xml(xml: &str) -> Vec<TestsuiteXml> {
    let mut reader = Reader::from_str(xml);
    let mut testsuites: Vec<TestsuiteXml> = Vec::new();
    let mut current_testsuite: Option<TestsuiteXml> = None;
    let mut current_testcase: Option<TestcaseXml> = None;
    let mut current_failure_content = String::new();
    let mut in_failure = false;
    let mut in_error = false;
    let mut failure_message: Option<String> = None;
    let mut error_message: Option<String> = None;

    loop {
        match reader.read_event() {
            Ok(Event::Start(e)) => match e.name().as_ref() {
                b"testsuite" => {
                    current_testsuite = Some(TestsuiteXml {
                        name: get_attr(&e, b"name").unwrap_or_default(),
                        tests: get_attr_i32(&e, b"tests"),
                        failures: get_attr_i32(&e, b"failures"),
                        errors: get_attr_i32(&e, b"errors"),
                        skipped: get_attr_i32(&e, b"skipped"),
                        time: get_attr_f64(&e, b"time"),
                        timestamp: get_attr(&e, b"timestamp"),
                        hostname: get_attr(&e, b"hostname"),
                        testcases: Vec::new(),
                    });
                }
                b"testcase" => {
                    current_testcase = Some(TestcaseXml {
                        name: get_attr(&e, b"name").unwrap_or_default(),
                        classname: get_attr(&e, b"classname"),
                        time: get_attr_f64(&e, b"time"),
                        failure: None,
                        error: None,
                    });
                }
                b"failure" => {
                    in_failure = true;
                    failure_message = get_attr(&e, b"message");
                    current_failure_content.clear();
                }
                b"error" => {
                    in_error = true;
                    error_message = get_attr(&e, b"message");
                    current_failure_content.clear();
                }
                _ => {}
            },
            Ok(Event::Empty(e)) => match e.name().as_ref() {
                b"testsuite" => {
                    testsuites.push(TestsuiteXml {
                        name: get_attr(&e, b"name").unwrap_or_default(),
                        tests: get_attr_i32(&e, b"tests"),
                        failures: get_attr_i32(&e, b"failures"),
                        errors: get_attr_i32(&e, b"errors"),
                        skipped: get_attr_i32(&e, b"skipped"),
                        time: get_attr_f64(&e, b"time"),
                        timestamp: get_attr(&e, b"timestamp"),
                        hostname: get_attr(&e, b"hostname"),
                        testcases: Vec::new(),
                    });
                }
                b"testcase" => {
                    let tc = TestcaseXml {
                        name: get_attr(&e, b"name").unwrap_or_default(),
                        classname: get_attr(&e, b"classname"),
                        time: get_attr_f64(&e, b"time"),
                        failure: None,
                        error: None,
                    };
                    if let Some(ref mut ts) = current_testsuite {
                        ts.testcases.push(tc);
                    }
                }
                _ => {}
            },
            Ok(Event::Text(e)) => {
                if (in_failure || in_error)
                    && let Ok(text) = e.unescape()
                {
                    current_failure_content.push_str(&text);
                }
            }
            Ok(Event::End(e)) => match e.name().as_ref() {
                b"testsuite" => {
                    if let Some(ts) = current_testsuite.take() {
                        testsuites.push(ts);
                    }
                }
                b"testcase" => {
                    if let Some(tc) = current_testcase.take()
                        && let Some(ref mut ts) = current_testsuite
                    {
                        ts.testcases.push(tc);
                    }
                }
                b"failure" => {
                    if let Some(ref mut tc) = current_testcase {
                        tc.failure = Some(FailureXml {
                            message: failure_message.take(),
                            content: std::mem::take(&mut current_failure_content),
                        });
                    }
                    in_failure = false;
                }
                b"error" => {
                    if let Some(ref mut tc) = current_testcase {
                        tc.error = Some(FailureXml {
                            message: error_message.take(),
                            content: std::mem::take(&mut current_failure_content),
                        });
                    }
                    in_error = false;
                }
                _ => {}
            },
            Ok(Event::Eof) => break,
            Err(_) => break,
            _ => {}
        }
    }

    testsuites
}

/// Writes testsuites to XML string using quick-xml Writer.
fn write_testsuites_xml(
    testsuites: &[TestsuiteXml],
    total_tests: i32,
    total_failures: i32,
    total_errors: i32,
    total_time: f64,
) -> String {
    let mut writer = Writer::new(Cursor::new(Vec::new()));

    let _ = writer.write_event(Event::Decl(BytesDecl::new("1.0", Some("UTF-8"), None)));
    let _ = writer.write_event(Event::Text(BytesText::new("\n")));

    let mut testsuites_elem = BytesStart::new("testsuites");
    testsuites_elem.push_attribute(("name", "offload"));
    testsuites_elem.push_attribute(("tests", total_tests.to_string().as_str()));
    testsuites_elem.push_attribute(("failures", total_failures.to_string().as_str()));
    testsuites_elem.push_attribute(("errors", total_errors.to_string().as_str()));
    testsuites_elem.push_attribute(("time", format!("{:.3}", total_time).as_str()));
    let _ = writer.write_event(Event::Start(testsuites_elem));
    let _ = writer.write_event(Event::Text(BytesText::new("\n")));

    for suite in testsuites {
        write_testsuite(&mut writer, suite);
    }

    let _ = writer.write_event(Event::End(BytesEnd::new("testsuites")));
    let _ = writer.write_event(Event::Text(BytesText::new("\n")));

    String::from_utf8(writer.into_inner().into_inner()).unwrap_or_default()
}

fn write_testsuite(writer: &mut Writer<Cursor<Vec<u8>>>, suite: &TestsuiteXml) {
    let _ = writer.write_event(Event::Text(BytesText::new("  ")));

    let mut elem = BytesStart::new("testsuite");
    elem.push_attribute(("name", suite.name.as_str()));
    elem.push_attribute(("tests", suite.tests.to_string().as_str()));
    elem.push_attribute(("failures", suite.failures.to_string().as_str()));
    elem.push_attribute(("errors", suite.errors.to_string().as_str()));
    elem.push_attribute(("skipped", suite.skipped.to_string().as_str()));
    elem.push_attribute(("time", format!("{:.3}", suite.time).as_str()));
    if let Some(ref ts) = suite.timestamp {
        elem.push_attribute(("timestamp", ts.as_str()));
    }
    if let Some(ref hn) = suite.hostname {
        elem.push_attribute(("hostname", hn.as_str()));
    }
    let _ = writer.write_event(Event::Start(elem));

    for tc in &suite.testcases {
        write_testcase(writer, tc);
    }

    let _ = writer.write_event(Event::End(BytesEnd::new("testsuite")));
    let _ = writer.write_event(Event::Text(BytesText::new("\n")));
}

fn write_testcase(writer: &mut Writer<Cursor<Vec<u8>>>, tc: &TestcaseXml) {
    let mut elem = BytesStart::new("testcase");
    elem.push_attribute(("name", tc.name.as_str()));
    if let Some(ref cn) = tc.classname {
        elem.push_attribute(("classname", cn.as_str()));
    }
    elem.push_attribute(("time", format!("{:.3}", tc.time).as_str()));

    let has_content = tc.failure.is_some() || tc.error.is_some();

    if has_content {
        let _ = writer.write_event(Event::Start(elem));
        if let Some(ref failure) = tc.failure {
            write_failure_or_error(writer, "failure", failure);
        }
        if let Some(ref error) = tc.error {
            write_failure_or_error(writer, "error", error);
        }
        let _ = writer.write_event(Event::End(BytesEnd::new("testcase")));
    } else {
        let _ = writer.write_event(Event::Empty(elem));
    }
}

fn write_failure_or_error(writer: &mut Writer<Cursor<Vec<u8>>>, tag: &str, failure: &FailureXml) {
    let mut elem = BytesStart::new(tag);
    if let Some(ref msg) = failure.message {
        elem.push_attribute(("message", msg.as_str()));
    }
    let _ = writer.write_event(Event::Start(elem));
    let _ = writer.write_event(Event::Text(BytesText::new(&failure.content)));
    let _ = writer.write_event(Event::End(BytesEnd::new(tag)));
}

/// Thread-safe handle to a MasterJunitReport.
pub type SharedJunitReport = Arc<Mutex<MasterJunitReport>>;

/// Loads test durations from an existing JUnit XML file.
///
/// If a test ID appears across multiple testsuites (retries), the max
/// duration is kept for conservative scheduling estimates.
pub fn load_test_durations(
    junit_path: &Path,
    test_id_format: &str,
) -> HashMap<String, std::time::Duration> {
    let mut durations = HashMap::new();

    let content = match std::fs::read_to_string(junit_path) {
        Ok(c) => c,
        Err(e) => {
            info!(
                "Could not read JUnit XML for durations: {} ({})",
                junit_path.display(),
                e
            );
            return durations;
        }
    };

    let mut reader = Reader::from_str(&content);

    loop {
        match reader.read_event() {
            Ok(Event::Start(e) | Event::Empty(e)) if e.name().as_ref() == b"testcase" => {
                let name = get_attr(&e, b"name");
                let classname = get_attr(&e, b"classname");
                let time = get_attr_f64(&e, b"time");

                if let Some(test_name) = name {
                    let test_id = crate::config::format_test_id(
                        test_id_format,
                        &test_name,
                        classname.as_deref(),
                    );
                    let duration = std::time::Duration::from_secs_f64(time);
                    durations
                        .entry(test_id)
                        .and_modify(|existing: &mut std::time::Duration| {
                            if duration > *existing {
                                *existing = duration;
                            }
                        })
                        .or_insert(duration);
                }
            }
            Ok(Event::Eof) => break,
            Err(_) => break,
            _ => {}
        }
    }

    info!(
        "Loaded {} test durations from {} using format '{}'",
        durations.len(),
        junit_path.display(),
        test_id_format
    );

    if !durations.is_empty() {
        let mut samples: Vec<_> = durations.iter().collect();
        samples.sort_by(|a, b| b.1.cmp(a.1));
        info!("Test durations loaded (sorted by duration):");
        for (test_id, duration) in samples.iter().take(10) {
            info!("  {:.3}s  {}", duration.as_secs_f64(), test_id);
        }
        if samples.len() > 10 {
            info!("  ... and {} more", samples.len() - 10);
        }
    }

    durations
}

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

    #[test]
    fn test_parse_testcase_passed() {
        let xml = r#"<?xml version="1.0"?>
<testsuite name="test" tests="1" failures="0">
    <testcase classname="foo.bar" name="test_something" time="0.1" />
</testsuite>"#;

        let mut report = MasterJunitReport::new(1, "{name}", HashSet::new());
        report.add_junit_xml(xml);

        assert_eq!(report.total_count(), 1);
        assert_eq!(report.passed_count(), 1);
        assert_eq!(report.failed_count(), 0);
    }

    #[test]
    fn test_parse_testcase_failed() {
        let xml = r#"<?xml version="1.0"?>
<testsuite name="test" tests="1" failures="1">
    <testcase classname="foo.bar" name="test_fail" time="0.1">
        <failure message="oops">stack trace</failure>
    </testcase>
</testsuite>"#;

        let mut report = MasterJunitReport::new(1, "{name}", HashSet::new());
        report.add_junit_xml(xml);

        assert_eq!(report.total_count(), 1);
        assert_eq!(report.passed_count(), 0);
        assert_eq!(report.failed_count(), 1);
    }

    #[test]
    fn test_flaky_detection() {
        let xml_fail = r#"<?xml version="1.0"?>
<testsuite name="test" tests="1" failures="1">
    <testcase classname="foo.bar" name="test_flaky" time="0.1">
        <failure message="oops">stack trace</failure>
    </testcase>
</testsuite>"#;

        let xml_pass = r#"<?xml version="1.0"?>
<testsuite name="test" tests="1" failures="0">
    <testcase classname="foo.bar" name="test_flaky" time="0.1" />
</testsuite>"#;

        let mut report = MasterJunitReport::new(1, "{name}", HashSet::new());
        report.add_junit_xml(xml_fail);
        assert_eq!(report.failed_count(), 1);

        report.add_junit_xml(xml_pass);
        assert_eq!(report.failed_count(), 0);
        assert_eq!(report.flaky_count(), 1);
        assert_eq!(report.passed_count(), 1); // flaky counts as passed
    }

    /// One test ID maps to 3 testcases (e.g. vitest describe.each).
    /// All pass in one suite → test ID passes.
    #[test]
    fn test_one_to_many_all_pass() {
        let xml = r#"<?xml version="1.0"?>
<testsuite name="batch" tests="3" failures="0">
    <testcase classname="a.test.ts:5" name="suite > dup" time="0.1" />
    <testcase classname="a.test.ts:5" name="suite > dup" time="0.2" />
    <testcase classname="a.test.ts:5" name="suite > dup" time="0.3" />
</testsuite>"#;

        let mut report = MasterJunitReport::new(1, "{name}", HashSet::new());
        report.add_junit_xml(xml);

        assert_eq!(report.total_count(), 1); // 1 unique test ID
        assert_eq!(report.passed_count(), 1);
        assert_eq!(report.failed_count(), 0);
        assert_eq!(report.testcase_count(), 3); // 3 raw testcases
    }

    /// One test ID maps to 3 testcases. One fails → test ID fails.
    #[test]
    fn test_one_to_many_partial_failure() {
        let xml = r#"<?xml version="1.0"?>
<testsuite name="batch" tests="3" failures="1">
    <testcase classname="a.test.ts:5" name="suite > dup" time="0.1" />
    <testcase classname="a.test.ts:5" name="suite > dup" time="0.2">
        <failure message="oops">stack</failure>
    </testcase>
    <testcase classname="a.test.ts:5" name="suite > dup" time="0.3" />
</testsuite>"#;

        let mut report = MasterJunitReport::new(1, "{name}", HashSet::new());
        report.add_junit_xml(xml);

        assert_eq!(report.total_count(), 1);
        assert_eq!(report.passed_count(), 0);
        assert_eq!(report.failed_count(), 1); // any failure → ID fails
    }

    /// Flaky detection with one-to-many: fails in first batch, passes in second.
    #[test]
    fn test_one_to_many_flaky() {
        let xml_fail = r#"<?xml version="1.0"?>
<testsuite name="batch1" tests="2" failures="1">
    <testcase classname="a.test.ts:5" name="suite > dup" time="0.1">
        <failure message="oops">stack</failure>
    </testcase>
    <testcase classname="a.test.ts:5" name="suite > dup" time="0.2" />
</testsuite>"#;

        let xml_pass = r#"<?xml version="1.0"?>
<testsuite name="batch2" tests="2" failures="0">
    <testcase classname="a.test.ts:5" name="suite > dup" time="0.1" />
    <testcase classname="a.test.ts:5" name="suite > dup" time="0.2" />
</testsuite>"#;

        let mut report = MasterJunitReport::new(1, "{name}", HashSet::new());
        report.add_junit_xml(xml_fail);
        assert_eq!(report.failed_count(), 1);

        report.add_junit_xml(xml_pass);
        assert_eq!(report.failed_count(), 0);
        assert_eq!(report.flaky_count(), 1);
        assert_eq!(report.passed_count(), 1);
    }

    #[test]
    fn test_has_test_passed_name_only_format() {
        let xml = r#"<?xml version="1.0"?>
<testsuite name="test" tests="2" failures="0">
    <testcase classname="foo.bar" name="test_alpha" time="0.1" />
    <testcase classname="foo.bar" name="test_beta" time="0.2" />
</testsuite>"#;

        let mut report = MasterJunitReport::new(2, "{name}", HashSet::new());
        report.add_junit_xml(xml);

        assert!(report.has_test_passed("test_alpha"));
        assert!(report.has_test_passed("test_beta"));
        assert!(!report.has_test_passed("test_gamma")); // unknown
        assert!(!report.has_test_passed("foo.bar")); // classname, not name
    }

    #[test]
    fn test_has_test_passed_classname_space_name_format() {
        let xml = r#"<?xml version="1.0"?>
<testsuite name="test" tests="1" failures="0">
    <testcase classname="my_binary" name="tests::test_something" time="0.5" />
</testsuite>"#;

        let mut report = MasterJunitReport::new(1, "{classname} {name}", HashSet::new());
        report.add_junit_xml(xml);

        assert!(report.has_test_passed("my_binary tests::test_something"));
        assert!(!report.has_test_passed("tests::test_something")); // name alone
        assert!(!report.has_test_passed("my_binary")); // classname alone
    }

    #[test]
    fn test_has_test_passed_classname_separator_name_format() {
        let xml = r#"<?xml version="1.0"?>
<testsuite name="test" tests="1" failures="0">
    <testcase classname="com.example" name="testFoo" time="0.1" />
</testsuite>"#;

        let mut report = MasterJunitReport::new(1, "{classname}::{name}", HashSet::new());
        report.add_junit_xml(xml);

        assert!(report.has_test_passed("com.example::testFoo"));
        assert!(!report.has_test_passed("testFoo"));
        assert!(!report.has_test_passed("com.example"));
    }

    #[test]
    fn test_has_test_passed_failed_test_returns_false() {
        let xml = r#"<?xml version="1.0"?>
<testsuite name="test" tests="1" failures="1">
    <testcase classname="foo" name="test_fail" time="0.1">
        <failure message="oops">stack trace</failure>
    </testcase>
</testsuite>"#;

        let mut report = MasterJunitReport::new(1, "{name}", HashSet::new());
        report.add_junit_xml(xml);

        assert!(!report.has_test_passed("test_fail"));
    }

    #[test]
    fn test_has_test_passed_flaky_test_returns_true() {
        let xml_fail = r#"<?xml version="1.0"?>
<testsuite name="batch1" tests="1" failures="1">
    <testcase classname="foo" name="test_flaky" time="0.1">
        <failure message="oops">stack</failure>
    </testcase>
</testsuite>"#;

        let xml_pass = r#"<?xml version="1.0"?>
<testsuite name="batch2" tests="1" failures="0">
    <testcase classname="foo" name="test_flaky" time="0.1" />
</testsuite>"#;

        let mut report = MasterJunitReport::new(1, "{name}", HashSet::new());
        report.add_junit_xml(xml_fail);
        assert!(!report.has_test_passed("test_flaky"));

        report.add_junit_xml(xml_pass);
        assert!(report.has_test_passed("test_flaky")); // flaky counts as passed
    }

    #[test]
    fn test_has_test_passed_no_classname_in_xml() {
        let xml = r#"<?xml version="1.0"?>
<testsuite name="test" tests="1" failures="0">
    <testcase name="test_solo" time="0.1" />
</testsuite>"#;

        // With {classname}::{name} format but no classname in XML
        let mut report = MasterJunitReport::new(1, "{classname}::{name}", HashSet::new());
        report.add_junit_xml(xml);

        assert!(report.has_test_passed("::test_solo")); // classname is empty
        assert!(!report.has_test_passed("test_solo")); // no match without ::
    }

    #[test]
    fn test_parse_all_testsuites_xml() {
        let xml =
            r#"<testsuite name="test" tests="5" failures="1" errors="2" time="1.23"></testsuite>"#;
        let suites = parse_all_testsuites_xml(xml);
        assert_eq!(suites.len(), 1);
        let suite = &suites[0];
        assert_eq!(suite.name, "test");
        assert_eq!(suite.tests, 5);
        assert_eq!(suite.failures, 1);
        assert_eq!(suite.errors, 2);
        assert!((suite.time - 1.23).abs() < 0.001);
    }

    #[test]
    fn test_load_test_durations() -> anyhow::Result<()> {
        use std::io::Write;

        let xml = r#"<?xml version="1.0"?>
<testsuites>
  <testsuite name="test">
    <testcase name="test_fast" classname="foo" time="0.5" />
    <testcase name="test_slow" classname="foo" time="10.0" />
    <testcase name="test_medium" classname="foo" time="2.5" />
  </testsuite>
</testsuites>"#;

        let dir = tempfile::tempdir()?;
        let path = dir.path().join("junit.xml");
        let mut file = std::fs::File::create(&path)?;
        file.write_all(xml.as_bytes())?;

        let durations = load_test_durations(&path, "{name}");

        assert_eq!(durations.len(), 3);
        assert_eq!(
            durations.get("test_fast"),
            Some(&std::time::Duration::from_millis(500))
        );
        assert_eq!(
            durations.get("test_slow"),
            Some(&std::time::Duration::from_secs(10))
        );
        assert_eq!(
            durations.get("test_medium"),
            Some(&std::time::Duration::from_millis(2500))
        );
        Ok(())
    }

    #[test]
    fn test_load_test_durations_uses_max_for_duplicates() -> anyhow::Result<()> {
        use std::io::Write;

        let xml = r#"<?xml version="1.0"?>
<testsuites>
  <testsuite name="batch1">
    <testcase name="test_flaky" classname="foo" time="1.0" />
  </testsuite>
  <testsuite name="batch2">
    <testcase name="test_flaky" classname="foo" time="3.0" />
  </testsuite>
</testsuites>"#;

        let dir = tempfile::tempdir()?;
        let path = dir.path().join("junit.xml");
        let mut file = std::fs::File::create(&path)?;
        file.write_all(xml.as_bytes())?;

        let durations = load_test_durations(&path, "{name}");

        assert_eq!(durations.len(), 1);
        assert_eq!(
            durations.get("test_flaky"),
            Some(&std::time::Duration::from_secs(3))
        );
        Ok(())
    }

    #[test]
    fn test_load_test_durations_nonexistent_file() {
        let durations = load_test_durations(Path::new("/nonexistent/path/junit.xml"), "{name}");
        assert!(durations.is_empty());
    }

    #[test]
    fn test_get_attr_unescapes_xml_entities() {
        let xml =
            r#"<testcase name="math &gt; add &gt; adds two" classname="tests/math.test.ts"/>"#;
        let suites = parse_all_testsuites_xml(&format!(
            r#"<?xml version="1.0"?><testsuites><testsuite name="s" tests="1" failures="0" errors="0" skipped="0" time="0">{}</testsuite></testsuites>"#,
            xml
        ));
        assert_eq!(suites.len(), 1);
        assert_eq!(suites[0].testcases.len(), 1);
        assert_eq!(suites[0].testcases[0].name, "math > add > adds two");
    }

    #[test]
    fn test_get_attr_unescapes_ampersand_and_quotes() {
        let xml = r#"<testsuite name="a &amp; b" tests="1" failures="0" errors="0" skipped="0" time="0"><testcase name="x &lt; y &gt; z &quot;q&quot;" classname="c" time="0"/></testsuite>"#;
        let suites = parse_all_testsuites_xml(&format!(
            r#"<?xml version="1.0"?><testsuites>{}</testsuites>"#,
            xml
        ));
        assert_eq!(suites[0].name, "a & b");
        assert_eq!(suites[0].testcases[0].name, r#"x < y > z "q""#);
    }

    #[test]
    fn test_unknown_test_id_warning() {
        let known: HashSet<String> = ["test_foo", "test_bar"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let mut report = MasterJunitReport::new(2, "{name}", known);

        let xml = r#"<?xml version="1.0"?>
<testsuite name="test" tests="1" failures="0">
    <testcase name="test_baz" time="0.1" />
</testsuite>"#;

        report.add_junit_xml(xml);

        // The report still works correctly despite the unknown ID
        assert_eq!(report.total_count(), 1);
        assert_eq!(report.passed_count(), 1);
        assert_eq!(report.failed_count(), 0);
    }

    #[test]
    fn test_known_test_ids_no_warning() {
        let known: HashSet<String> = ["test_alpha", "test_beta"]
            .iter()
            .map(|s| s.to_string())
            .collect();
        let mut report = MasterJunitReport::new(2, "{name}", known);

        let xml = r#"<?xml version="1.0"?>
<testsuite name="test" tests="2" failures="0">
    <testcase name="test_alpha" time="0.1" />
    <testcase name="test_beta" time="0.2" />
</testsuite>"#;

        report.add_junit_xml(xml);

        assert_eq!(report.total_count(), 2);
        assert_eq!(report.passed_count(), 2);
        assert_eq!(report.failed_count(), 0);
    }

    #[test]
    fn test_levenshtein_distance_basic() {
        assert_eq!(edit_distance::edit_distance("", ""), 0);
        assert_eq!(edit_distance::edit_distance("abc", "abc"), 0);
        assert_eq!(edit_distance::edit_distance("abc", ""), 3);
        assert_eq!(edit_distance::edit_distance("", "abc"), 3);
        assert_eq!(edit_distance::edit_distance("kitten", "sitting"), 3);
        assert_eq!(edit_distance::edit_distance("test_foo", "test_bar"), 3);
    }
}
