//! Reporter-side failure-source grouping (WOR-1469).
//!
//! The classifier in [`crate::eval::failure_source`] works on a single
//! `(message, status, tool_name)` triple. Reporters work on a whole
//! [`Report`], so this helper bridges the two: it walks the failed tests,
//! classifies each from its [`crate::report::TestFailure`] message (folding in
//! the network diagnostic's URL and message so a host substring can match), and
//! folds the results into a stable source breakdown plus a JSON value the JSON
//! reporter can attach without growing the canonical [`Report`] shape. It is
//! additive: a reporter that ignores it sees no change.

use serde_json::{json, Value};

use crate::eval::failure_source::{classify_failure, group_by_source, Classification, SourceCount};
use crate::report::{Report, TestStatus};

/// Classify every failed test in a report into its likely source.
///
/// Returns one [`Classification`] per failing test, in report order, so the
/// caller can line them up against the failures. Tests without a populated
/// failure payload are skipped (a `Fail` with no message is unclassifiable).
pub fn classify_report_failures(report: &Report) -> Vec<Classification> {
    report
        .tests
        .iter()
        .filter(|t| matches!(t.status, TestStatus::Fail))
        .filter_map(|t| {
            let failure = t.failure.as_ref()?;
            let message = enriched_message(failure);
            Some(classify_failure(&message, None, Some(&t.name)))
        })
        .collect()
}

/// Group a report's failures by source into a stable, fixed-order breakdown.
///
/// A thin wrapper over [`classify_report_failures`] and
/// [`group_by_source`] so a reporter renders "2 transient, 1 code_bug" without
/// re-implementing the fold.
pub fn failure_source_breakdown(report: &Report) -> Vec<SourceCount> {
    group_by_source(&classify_report_failures(report))
}

/// The JSON breakdown the JSON reporter can attach under a `failure_sources`
/// key.
///
/// Shaped as an array of `{ "source": <label>, "count": <n> }` objects so a CI
/// dashboard pivots on the source string without re-parsing free text. Empty
/// array when no failure was classifiable.
pub fn failure_source_json(report: &Report) -> Value {
    let breakdown = failure_source_breakdown(report);
    Value::Array(
        breakdown
            .into_iter()
            .map(|b| {
                json!({
                    "source": b.source.label(),
                    "count": b.count,
                })
            })
            .collect(),
    )
}

/// Fold a failure's network diagnostic (when present) into the classifier
/// haystack so a host or URL substring can attribute the failure to a known
/// service that the bare assertion message would miss.
fn enriched_message(failure: &crate::report::TestFailure) -> String {
    match &failure.network_diagnostic {
        Some(diag) => format!("{} {} {}", failure.message, diag.url, diag.message),
        None => failure.message.clone(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::eval::failure_source::FailureSource;
    use crate::network::{Layer, NetworkDiagnostic};
    use crate::report::{Report, TestFailure, TestStatus};

    fn fail_with(name: &str, message: &str) -> crate::report::TestResult {
        let mut t = Report::sample().tests.remove(0);
        t.name = name.to_string();
        t.status = TestStatus::Fail;
        t.failure = Some(TestFailure {
            message: message.to_string(),
            diff: None,
            network_diagnostic: None,
        });
        t
    }

    #[test]
    fn classifies_each_failed_test_in_report_order() {
        let mut r = Report::sample();
        r.tests = vec![
            fail_with("a", "request timed out after 30s"),
            fail_with("b", "assertion failed: expected 3"),
        ];
        let classes = classify_report_failures(&r);
        assert_eq!(classes.len(), 2);
        assert_eq!(classes[0].source, FailureSource::Transient);
        assert_eq!(classes[1].source, FailureSource::CodeBug);
    }

    #[test]
    fn skips_passing_tests() {
        let r = Report::sample(); // two passes, one fail with a diff
        let classes = classify_report_failures(&r);
        // Only the single failing sample test is classified.
        assert_eq!(classes.len(), 1);
    }

    #[test]
    fn breakdown_counts_by_source() {
        let mut r = Report::sample();
        r.tests = vec![
            fail_with("a", "rate limit hit"),
            fail_with("b", "request timed out"),
            fail_with("c", "panic in handler"),
        ];
        let breakdown = failure_source_breakdown(&r);
        assert_eq!(breakdown.len(), 2);
        assert_eq!(breakdown[0].source, FailureSource::CodeBug);
        assert_eq!(breakdown[0].count, 1);
        assert_eq!(breakdown[1].source, FailureSource::Transient);
        assert_eq!(breakdown[1].count, 2);
    }

    #[test]
    fn json_shape_is_source_and_count_objects() {
        let mut r = Report::sample();
        r.tests = vec![fail_with("a", "request timed out")];
        let value = failure_source_json(&r);
        let arr = value.as_array().expect("array");
        assert_eq!(arr.len(), 1);
        assert_eq!(arr[0]["source"], "transient");
        assert_eq!(arr[0]["count"], 1);
    }

    #[test]
    fn network_diagnostic_url_lets_a_host_match_an_external_service() {
        let mut r = Report::sample();
        let mut t = fail_with("a", "connection reset");
        t.failure = Some(TestFailure {
            message: "connection reset".into(),
            diff: None,
            network_diagnostic: Some(NetworkDiagnostic::new(
                Layer::Tcp,
                "https://api.github.com/repos",
                "reset by peer",
            )),
        });
        r.tests = vec![t];
        let classes = classify_report_failures(&r);
        assert_eq!(classes[0].source, FailureSource::ExternalDependency);
    }

    #[test]
    fn empty_breakdown_when_no_failures() {
        let mut r = Report::sample();
        r.tests.retain(|t| matches!(t.status, TestStatus::Pass));
        assert!(failure_source_breakdown(&r).is_empty());
        assert!(failure_source_json(&r).as_array().unwrap().is_empty());
    }
}
