//! Deterministic failure-source classification to cut false CI reds (WOR-1469).
//!
//! A red CI run is only actionable if the operator knows *why* the call failed,
//! not just that it did. A 503 from a known upstream is a different signal from
//! a missing API key, which is different again from a genuine assertion mismatch
//! in the server under test. This module reads a failure's message, optional
//! HTTP status, and optional tool name and assigns one of five sources with a
//! confidence and a one-line remediation, all from pure deterministic rules
//! (no model in the loop). The known-external-service table is a small `const`
//! that is easy to extend as new upstreams show up in the wild. Pairing this
//! with the opt-in [`crate::eval::failure_source`] policy lets a run downgrade
//! transient or external failures out of the exit code so a flaky upstream does
//! not gate a merge.

/// Why a tool call or assertion failed, deciding whether the failure is the
/// server-under-test's fault or someone else's.
///
/// The ordering reflects how the classifier prefers an explanation: a transient
/// or environment signal wins over an external-service match, which wins over a
/// code-bug guess, which wins over the [`FailureSource::Unknown`] fallback. The
/// enum is `#[non_exhaustive]` so a downstream `match` keeps compiling when a
/// future source is added.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum FailureSource {
    /// A known upstream dependency failed (an external API the server calls),
    /// so the failure is not the server-under-test's fault.
    ExternalDependency,
    /// The local environment is misconfigured: a missing env var, an unset
    /// credential, or a 401/403 from a missing key.
    Environment,
    /// A genuine defect in the server under test: an assertion mismatch, a
    /// panic, or an unexpected result with no external or environment signal.
    CodeBug,
    /// A transient failure that usually clears on retry: a timeout, a rate
    /// limit, or a 5xx with no other signal.
    Transient,
    /// No rule matched with enough confidence to commit to a source.
    Unknown,
}

impl FailureSource {
    /// The stable lower-case label used in reports and JSON so downstream
    /// tooling pivots on the source without re-parsing the message.
    pub fn label(self) -> &'static str {
        match self {
            FailureSource::ExternalDependency => "external_dependency",
            FailureSource::Environment => "environment",
            FailureSource::CodeBug => "code_bug",
            FailureSource::Transient => "transient",
            FailureSource::Unknown => "unknown",
        }
    }
}

/// The verdict for one failure: its source, how sure the classifier is, and
/// what to do next.
///
/// Confidence is a 0..1 fraction so callers can gate on it (the classifier
/// itself falls back to [`FailureSource::Unknown`] below
/// [`CONFIDENCE_FLOOR`]). The remediation is a single actionable sentence so a
/// reporter can print it verbatim next to the failure.
#[derive(Debug, Clone, PartialEq)]
pub struct Classification {
    /// The assigned source.
    pub source: FailureSource,
    /// Confidence in the assignment, as a fraction in `[0, 1]`.
    pub confidence: f64,
    /// One actionable sentence telling the operator what to do next.
    pub remediation: String,
}

/// Confidence below which the classifier reports [`FailureSource::Unknown`]
/// rather than committing to a weak guess.
///
/// Set so a single substring match (the weakest rule) still clears the floor
/// but the synthetic "no rule fired" fallback does not. Keeping it public lets
/// callers and tests reason about the gate.
pub const CONFIDENCE_FLOOR: f64 = 0.5;

/// One row of the known-external-service table: a service and the substrings
/// that, when found in a failure message, attribute the failure to it.
///
/// The patterns are matched case-insensitively against the message and the tool
/// name. Keeping this a flat struct (rather than per-service code) makes the
/// table a one-line edit to extend when a new upstream appears.
#[derive(Debug, Clone, Copy)]
pub struct ExternalService {
    /// Human-readable service name used in the remediation sentence.
    pub name: &'static str,
    /// Lower-case substrings (host fragments or error markers) that attribute
    /// a failure to this service. Any match counts.
    pub patterns: &'static [&'static str],
}

/// The bundled known-external-service table.
///
/// Extend by appending a row; the classifier matches any pattern
/// case-insensitively against the failure message and the tool name. The list
/// is intentionally small and curated rather than exhaustive: a false negative
/// degrades to [`FailureSource::Unknown`] (a normal red), while a false
/// positive could mask a real bug, so we only add hosts and markers we are
/// confident name a third-party dependency.
pub const KNOWN_EXTERNAL_SERVICES: &[ExternalService] = &[
    ExternalService {
        name: "OpenAI",
        patterns: &["api.openai.com", "openai"],
    },
    ExternalService {
        name: "Anthropic",
        patterns: &["api.anthropic.com", "anthropic"],
    },
    ExternalService {
        name: "GitHub",
        patterns: &["api.github.com", "github.com", "github api"],
    },
    ExternalService {
        name: "AWS",
        patterns: &["amazonaws.com", "aws", "s3.amazonaws"],
    },
    ExternalService {
        name: "Google Cloud",
        patterns: &["googleapis.com", "gcp", "google cloud"],
    },
    ExternalService {
        name: "Stripe",
        patterns: &["api.stripe.com", "stripe"],
    },
    ExternalService {
        name: "Slack",
        patterns: &["slack.com", "slack api"],
    },
];

/// Classify why a failure happened from its message, optional HTTP status, and
/// optional tool name.
///
/// The rules run in priority order so the most specific explanation wins:
/// transient (status 5xx/429 or timeout/rate-limit text) first, then
/// environment (missing key / unauthorized), then a known-external-service
/// match, then code-bug (assertion / panic / unexpected with no external or
/// environment signal), then [`FailureSource::Unknown`]. The function is pure
/// and deterministic so the same inputs always yield the same verdict.
pub fn classify_failure(
    message: &str,
    status: Option<u16>,
    tool_name: Option<&str>,
) -> Classification {
    let haystack = message.to_lowercase();
    let tool = tool_name.map(str::to_lowercase).unwrap_or_default();

    if let Some(c) = classify_transient(&haystack, status) {
        return c;
    }
    if let Some(c) = classify_environment(&haystack, status) {
        return c;
    }
    if let Some(c) = classify_external(&haystack, &tool) {
        return c;
    }
    if let Some(c) = classify_code_bug(&haystack) {
        return c;
    }
    unknown()
}

/// Transient rule: 5xx/429 status or timeout/rate-limit/unavailable text.
///
/// These usually clear on retry, so the remediation is to retry rather than to
/// edit code. A status code is stronger evidence than a text match, so it
/// carries the higher confidence.
fn classify_transient(haystack: &str, status: Option<u16>) -> Option<Classification> {
    if let Some(code) = status {
        if code == 429 || (500..=599).contains(&code) {
            return Some(Classification {
                source: FailureSource::Transient,
                confidence: 0.9,
                remediation: format!(
                    "Upstream returned HTTP {code}; retry the run, the failure is likely transient."
                ),
            });
        }
    }
    let markers = [
        "timeout",
        "timed out",
        "rate limit",
        "temporarily unavailable",
    ];
    if markers.iter().any(|m| haystack.contains(m)) {
        return Some(Classification {
            source: FailureSource::Transient,
            confidence: 0.75,
            remediation:
                "Transient failure (timeout or rate limit); retry the run before treating it as a defect."
                    .to_string(),
        });
    }
    None
}

/// Environment rule: missing env var / unset credential / 401 / 403.
///
/// The fix is to set configuration, not to retry or edit server code, so the
/// remediation points at credentials and environment.
fn classify_environment(haystack: &str, status: Option<u16>) -> Option<Classification> {
    if matches!(status, Some(401) | Some(403)) {
        return Some(Classification {
            source: FailureSource::Environment,
            confidence: 0.85,
            remediation:
                "Authentication rejected (401/403); check the API key or token in your environment."
                    .to_string(),
        });
    }
    let markers = [
        "missing env",
        "not set",
        "no api key",
        "unauthorized",
        "401",
        "403",
        "missing credential",
        "env var",
    ];
    if markers.iter().any(|m| haystack.contains(m)) {
        return Some(Classification {
            source: FailureSource::Environment,
            confidence: 0.8,
            remediation:
                "Environment misconfiguration; set the missing variable or credential and re-run."
                    .to_string(),
        });
    }
    None
}

/// External-dependency rule: the message or tool name matches a known upstream.
///
/// Confidence is moderate because a match attributes the failure to a third
/// party; the remediation names the service so the operator checks its status
/// rather than the server under test.
fn classify_external(haystack: &str, tool: &str) -> Option<Classification> {
    for service in KNOWN_EXTERNAL_SERVICES {
        if service
            .patterns
            .iter()
            .any(|p| haystack.contains(p) || tool.contains(p))
        {
            return Some(Classification {
                source: FailureSource::ExternalDependency,
                confidence: 0.7,
                remediation: format!(
                    "Failure traces to {} (an external dependency); check its status before treating it as a server bug.",
                    service.name
                ),
            });
        }
    }
    None
}

/// Code-bug rule: assertion / panic / unexpected text with no external or
/// environment signal already consumed by the earlier rules.
///
/// Reached only after the transient, environment, and external rules declined,
/// so a match here is a genuine defect in the server under test.
fn classify_code_bug(haystack: &str) -> Option<Classification> {
    let markers = [
        "assertion",
        "assert",
        "panic",
        "unexpected",
        "did not match",
        "expected",
    ];
    if markers.iter().any(|m| haystack.contains(m)) {
        return Some(Classification {
            source: FailureSource::CodeBug,
            confidence: 0.65,
            remediation:
                "Assertion or unexpected result with no upstream signal; fix the server under test."
                    .to_string(),
        });
    }
    None
}

/// The fallback verdict when no rule cleared the confidence floor.
fn unknown() -> Classification {
    Classification {
        source: FailureSource::Unknown,
        confidence: 0.0,
        remediation: "Could not classify the failure; inspect the message and logs manually."
            .to_string(),
    }
}

/// What the runner should do with a classified failure under the exit-code
/// policy.
///
/// `Gate` keeps the failure in the non-zero exit code (the default for every
/// source). `Downgrade` removes it from the exit code and surfaces it as a
/// warning instead, which is what the opt-in policy does to transient and
/// external failures so a flaky upstream does not turn the build red.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Disposition {
    /// Count the failure toward the non-zero exit code (the safe default).
    Gate,
    /// Keep the failure out of the exit code; report it as a warning.
    Downgrade,
}

/// The opt-in policy that decides whether classified failures still gate.
///
/// Off by default, so classification is purely informational unless a run asks
/// for it. When `downgrade_transient` (or `downgrade_external`) is set, a
/// failure classified to that source with confidence at or above the floor is
/// downgraded to a warning rather than gating the exit code. Code-bug and
/// environment failures always gate: a downgraded real defect or a missing key
/// is worse than a flaky upstream slipping through.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct FailurePolicy {
    /// Downgrade [`FailureSource::Transient`] failures out of the exit code.
    pub downgrade_transient: bool,
    /// Downgrade [`FailureSource::ExternalDependency`] failures out of the exit
    /// code.
    pub downgrade_external: bool,
}

impl FailurePolicy {
    /// True when the policy never downgrades anything (a no-op).
    pub fn is_off(self) -> bool {
        !self.downgrade_transient && !self.downgrade_external
    }
}

/// Decide a classified failure's exit-code disposition under a policy.
///
/// Pure and deterministic: only transient and external sources are eligible for
/// a downgrade, only when the matching policy flag is set and the
/// classification cleared [`CONFIDENCE_FLOOR`]. Everything else gates, so a
/// low-confidence guess or a genuine defect never silently leaves the exit
/// code.
pub fn apply_policy(classification: &Classification, policy: FailurePolicy) -> Disposition {
    if classification.confidence < CONFIDENCE_FLOOR {
        return Disposition::Gate;
    }
    let downgrade = match classification.source {
        FailureSource::Transient => policy.downgrade_transient,
        FailureSource::ExternalDependency => policy.downgrade_external,
        _ => false,
    };
    if downgrade {
        Disposition::Downgrade
    } else {
        Disposition::Gate
    }
}

/// One bucket of a source breakdown: a source and how many failures landed in
/// it.
///
/// A reporter renders these so an operator sees "3 transient, 1 code_bug" at a
/// glance and knows which reds are worth investigating.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct SourceCount {
    /// The failure source for this bucket.
    pub source: FailureSource,
    /// How many classified failures landed in it.
    pub count: usize,
}

/// Group classified failures by source into a stable, fixed-order breakdown.
///
/// The order follows [`FailureSource`]'s declaration so two runs with the same
/// failures produce the same breakdown (useful for snapshot tests and diffing).
/// Sources with no failures are omitted so the breakdown stays compact.
pub fn group_by_source(classifications: &[Classification]) -> Vec<SourceCount> {
    const ORDER: [FailureSource; 5] = [
        FailureSource::ExternalDependency,
        FailureSource::Environment,
        FailureSource::CodeBug,
        FailureSource::Transient,
        FailureSource::Unknown,
    ];
    ORDER
        .into_iter()
        .filter_map(|source| {
            let count = classifications
                .iter()
                .filter(|c| c.source == source)
                .count();
            (count > 0).then_some(SourceCount { source, count })
        })
        .collect()
}

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

    #[test]
    fn http_5xx_status_classifies_as_transient() {
        let c = classify_failure("server error", Some(503), None);
        assert_eq!(c.source, FailureSource::Transient);
        assert!(c.confidence >= CONFIDENCE_FLOOR);
        assert!(c.remediation.contains("503"));
    }

    #[test]
    fn http_429_status_classifies_as_transient() {
        let c = classify_failure("too many requests", Some(429), None);
        assert_eq!(c.source, FailureSource::Transient);
    }

    #[test]
    fn timeout_text_classifies_as_transient() {
        let c = classify_failure("request timed out after 30s", None, None);
        assert_eq!(c.source, FailureSource::Transient);
        assert!(c.confidence >= CONFIDENCE_FLOOR);
    }

    #[test]
    fn rate_limit_text_classifies_as_transient() {
        let c = classify_failure("hit the provider rate limit", None, None);
        assert_eq!(c.source, FailureSource::Transient);
    }

    #[test]
    fn temporarily_unavailable_classifies_as_transient() {
        let c = classify_failure("service temporarily unavailable", None, None);
        assert_eq!(c.source, FailureSource::Transient);
    }

    #[test]
    fn unauthorized_status_classifies_as_environment() {
        let c = classify_failure("forbidden", Some(403), None);
        assert_eq!(c.source, FailureSource::Environment);
        assert!(c.confidence >= CONFIDENCE_FLOOR);
    }

    #[test]
    fn missing_api_key_text_classifies_as_environment() {
        let c = classify_failure("no api key found in environment", None, None);
        assert_eq!(c.source, FailureSource::Environment);
    }

    #[test]
    fn unset_env_var_classifies_as_environment() {
        let c = classify_failure("env var OPENAI_API_KEY not set", None, None);
        assert_eq!(c.source, FailureSource::Environment);
    }

    #[test]
    fn known_host_in_message_classifies_as_external() {
        let c = classify_failure("connection reset by api.github.com", None, None);
        assert_eq!(c.source, FailureSource::ExternalDependency);
        assert!(c.remediation.contains("GitHub"));
    }

    #[test]
    fn known_service_in_tool_name_classifies_as_external() {
        let c = classify_failure("call returned an error", None, Some("openai_chat"));
        assert_eq!(c.source, FailureSource::ExternalDependency);
        assert!(c.remediation.contains("OpenAI"));
    }

    #[test]
    fn assertion_text_classifies_as_code_bug() {
        let c = classify_failure("assertion failed: expected 3 results, got 0", None, None);
        assert_eq!(c.source, FailureSource::CodeBug);
        assert!(c.confidence >= CONFIDENCE_FLOOR);
    }

    #[test]
    fn panic_text_classifies_as_code_bug() {
        let c = classify_failure("server panicked while handling the request", None, None);
        assert_eq!(c.source, FailureSource::CodeBug);
    }

    #[test]
    fn unexpected_result_classifies_as_code_bug() {
        let c = classify_failure("unexpected value in response payload", None, None);
        assert_eq!(c.source, FailureSource::CodeBug);
    }

    #[test]
    fn unrecognized_message_falls_back_to_unknown() {
        let c = classify_failure("something odd happened", None, None);
        assert_eq!(c.source, FailureSource::Unknown);
        assert!(c.confidence < CONFIDENCE_FLOOR);
    }

    #[test]
    fn transient_status_beats_external_host_in_message() {
        // A 503 from a known host is still transient, not attributed to the
        // service, because retrying is the right next action.
        let c = classify_failure("api.openai.com returned an error", Some(503), None);
        assert_eq!(c.source, FailureSource::Transient);
    }

    #[test]
    fn environment_beats_external_when_both_signals_present() {
        // A 401 from a known host is an environment problem (bad key), not an
        // upstream outage.
        let c = classify_failure("api.github.com said unauthorized", Some(401), None);
        assert_eq!(c.source, FailureSource::Environment);
    }

    #[test]
    fn policy_off_gates_every_source() {
        let policy = FailurePolicy::default();
        assert!(policy.is_off());
        for c in [
            classify_failure("timed out", None, None),
            classify_failure("api.openai.com error", None, None),
            classify_failure("assertion failed", None, None),
        ] {
            assert_eq!(apply_policy(&c, policy), Disposition::Gate);
        }
    }

    #[test]
    fn policy_downgrades_transient_when_enabled() {
        let policy = FailurePolicy {
            downgrade_transient: true,
            downgrade_external: false,
        };
        let c = classify_failure("request timed out", None, None);
        assert_eq!(apply_policy(&c, policy), Disposition::Downgrade);
    }

    #[test]
    fn policy_downgrades_external_when_enabled() {
        let policy = FailurePolicy {
            downgrade_transient: false,
            downgrade_external: true,
        };
        let c = classify_failure("api.github.com reset the connection", None, None);
        assert_eq!(apply_policy(&c, policy), Disposition::Downgrade);
    }

    #[test]
    fn policy_never_downgrades_code_bugs() {
        let policy = FailurePolicy {
            downgrade_transient: true,
            downgrade_external: true,
        };
        let c = classify_failure("assertion failed: expected 3", None, None);
        assert_eq!(apply_policy(&c, policy), Disposition::Gate);
    }

    #[test]
    fn policy_never_downgrades_environment() {
        let policy = FailurePolicy {
            downgrade_transient: true,
            downgrade_external: true,
        };
        let c = classify_failure("forbidden", Some(403), None);
        assert_eq!(apply_policy(&c, policy), Disposition::Gate);
    }

    #[test]
    fn low_confidence_unknown_always_gates() {
        let policy = FailurePolicy {
            downgrade_transient: true,
            downgrade_external: true,
        };
        let c = classify_failure("something odd", None, None);
        assert_eq!(c.source, FailureSource::Unknown);
        assert_eq!(apply_policy(&c, policy), Disposition::Gate);
    }

    #[test]
    fn group_by_source_counts_in_declared_order_and_omits_empties() {
        let cs = vec![
            classify_failure("timed out", None, None),
            classify_failure("rate limit", None, None),
            classify_failure("assertion failed", None, None),
        ];
        let groups = group_by_source(&cs);
        // Two transient + one code-bug; external/environment/unknown omitted.
        assert_eq!(groups.len(), 2);
        assert_eq!(groups[0].source, FailureSource::CodeBug);
        assert_eq!(groups[0].count, 1);
        assert_eq!(groups[1].source, FailureSource::Transient);
        assert_eq!(groups[1].count, 2);
    }

    #[test]
    fn group_by_source_is_empty_for_no_failures() {
        assert!(group_by_source(&[]).is_empty());
    }

    #[test]
    fn every_source_has_a_distinct_label() {
        let labels = [
            FailureSource::ExternalDependency.label(),
            FailureSource::Environment.label(),
            FailureSource::CodeBug.label(),
            FailureSource::Transient.label(),
            FailureSource::Unknown.label(),
        ];
        let mut seen = std::collections::BTreeSet::new();
        for l in labels {
            assert!(seen.insert(l), "duplicate label `{l}`");
        }
    }
}
