//! Coverage threshold parser and evaluator.
//!
//! `Thresholds::parse("tools=80,args=60")` builds a structured threshold
//! set; `Thresholds::evaluate` checks a [`CoverageReport`] against the
//! parsed thresholds and returns the unmet entries so the CLI agent can
//! print a clean error and exit non-zero.
//!
//! Keeping parsing and evaluation in the library lets the CLI agent wire
//! the flag in a one-line follow-up: parse the `--coverage-threshold`
//! argument, compare against the run's report, exit 6 when the result is
//! non-empty.

use std::fmt;

use super::report::CoverageReport;

/// One configured threshold: which dimension and what percentage.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Threshold {
    /// Dimension the threshold gates on.
    pub kind: ThresholdKind,
    /// Required percentage (0.0 to 100.0). A report meets the threshold
    /// when its observed percentage is greater than or equal to this.
    pub min_pct: f64,
}

/// Dimensions a threshold can gate on.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ThresholdKind {
    /// Tool coverage (fraction of declared tools called).
    Tools,
    /// Pairwise argument coverage.
    Args,
    /// Error-path coverage.
    ErrorPaths,
    /// Transport coverage.
    Transports,
}

impl ThresholdKind {
    /// Wire form used in CLI input and in error output.
    pub fn as_str(self) -> &'static str {
        match self {
            ThresholdKind::Tools => "tools",
            ThresholdKind::Args => "args",
            ThresholdKind::ErrorPaths => "error_paths",
            ThresholdKind::Transports => "transports",
        }
    }
}

/// Set of configured thresholds.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Thresholds {
    /// Individual entries in the order they were parsed.
    pub entries: Vec<Threshold>,
}

/// A single threshold that the report failed to meet.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct UnmetThreshold {
    /// The threshold that failed.
    pub threshold: Threshold,
    /// The percentage the report actually achieved.
    pub actual_pct: f64,
}

impl fmt::Display for UnmetThreshold {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(
            f,
            "{} coverage {:.1}% below required {:.1}%",
            self.threshold.kind.as_str(),
            self.actual_pct,
            self.threshold.min_pct
        )
    }
}

/// Failure modes returned by [`Thresholds::parse`].
#[derive(Debug, Clone, PartialEq)]
pub enum ParseError {
    /// Input did not match the `key=value[,key=value...]` grammar.
    Malformed(String),
    /// Recognised the key but it is not a known dimension.
    UnknownKind(String),
    /// Recognised the value as numeric but it was outside [0, 100].
    OutOfRange(f64),
    /// Could not parse the value as a number.
    InvalidNumber(String),
}

impl fmt::Display for ParseError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            ParseError::Malformed(s) => write!(f, "malformed threshold: {s}"),
            ParseError::UnknownKind(s) => write!(f, "unknown threshold dimension: {s}"),
            ParseError::OutOfRange(n) => write!(f, "threshold {n} outside 0..=100"),
            ParseError::InvalidNumber(s) => write!(f, "not a number: {s}"),
        }
    }
}

impl std::error::Error for ParseError {}

impl Thresholds {
    /// Parse the `--coverage-threshold` argument.
    ///
    /// Accepts `tools=80,args=60,error_paths=50,transports=100`. Whitespace
    /// around tokens is stripped. Duplicate keys keep the last value, on
    /// the assumption that a later flag override should win.
    pub fn parse(input: &str) -> Result<Self, ParseError> {
        let mut entries: Vec<Threshold> = Vec::new();
        for raw in input.split(',') {
            let token = raw.trim();
            if token.is_empty() {
                continue;
            }
            let (key, value) = token
                .split_once('=')
                .ok_or_else(|| ParseError::Malformed(token.to_owned()))?;
            let kind = match key.trim().to_ascii_lowercase().as_str() {
                "tools" => ThresholdKind::Tools,
                "args" | "arg" | "arg_coverage" => ThresholdKind::Args,
                "error_paths" | "errors" | "error-paths" => ThresholdKind::ErrorPaths,
                "transports" | "transport" => ThresholdKind::Transports,
                other => return Err(ParseError::UnknownKind(other.to_owned())),
            };
            let value_str = value.trim();
            let pct: f64 = value_str
                .parse()
                .map_err(|_| ParseError::InvalidNumber(value_str.to_owned()))?;
            if !(0.0..=100.0).contains(&pct) {
                return Err(ParseError::OutOfRange(pct));
            }
            // Drop a prior entry that targets the same kind so the last
            // declaration wins, matching how operators tend to think about
            // overriding flags.
            entries.retain(|t| t.kind != kind);
            entries.push(Threshold { kind, min_pct: pct });
        }
        Ok(Self { entries })
    }

    /// Evaluate the thresholds against a report. Returns `Ok(())` when
    /// every threshold is met; otherwise lists every dimension that
    /// fell short so the caller can print all the failures at once.
    pub fn evaluate(&self, report: &CoverageReport) -> Result<(), Vec<UnmetThreshold>> {
        let mut unmet: Vec<UnmetThreshold> = Vec::new();
        for entry in &self.entries {
            let actual = match entry.kind {
                ThresholdKind::Tools => report.tool_coverage_pct,
                ThresholdKind::Args => report.arg_coverage_pct,
                ThresholdKind::ErrorPaths => report.error_path_coverage_pct,
                ThresholdKind::Transports => report.transport_coverage_pct,
            };
            if actual + f64::EPSILON < entry.min_pct {
                unmet.push(UnmetThreshold {
                    threshold: *entry,
                    actual_pct: actual,
                });
            }
        }
        if unmet.is_empty() {
            Ok(())
        } else {
            Err(unmet)
        }
    }
}

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

    #[test]
    fn parse_accepts_two_dimension_input() {
        let t = Thresholds::parse("tools=80,args=60").unwrap();
        assert_eq!(t.entries.len(), 2);
        assert_eq!(t.entries[0].kind, ThresholdKind::Tools);
        assert_eq!(t.entries[0].min_pct, 80.0);
        assert_eq!(t.entries[1].kind, ThresholdKind::Args);
    }

    #[test]
    fn parse_accepts_all_four_dimensions() {
        let t = Thresholds::parse("tools=80,args=60,error_paths=40,transports=100").unwrap();
        assert_eq!(t.entries.len(), 4);
    }

    #[test]
    fn parse_rejects_unknown_dimension() {
        let err = Thresholds::parse("zebras=99").unwrap_err();
        assert_eq!(err, ParseError::UnknownKind("zebras".into()));
    }

    #[test]
    fn parse_rejects_out_of_range() {
        let err = Thresholds::parse("tools=150").unwrap_err();
        assert_eq!(err, ParseError::OutOfRange(150.0));
    }

    #[test]
    fn parse_rejects_non_numeric_value() {
        let err = Thresholds::parse("tools=high").unwrap_err();
        assert!(matches!(err, ParseError::InvalidNumber(_)));
    }

    #[test]
    fn parse_skips_empty_tokens() {
        let t = Thresholds::parse("tools=50,,args=40").unwrap();
        assert_eq!(t.entries.len(), 2);
    }

    #[test]
    fn evaluate_passes_when_all_thresholds_met() {
        let mut r = CoverageReport::sample();
        r.tool_coverage_pct = 90.0;
        r.arg_coverage_pct = 80.0;
        let t = Thresholds::parse("tools=80,args=60").unwrap();
        assert!(t.evaluate(&r).is_ok());
    }

    #[test]
    fn evaluate_returns_each_unmet_threshold() {
        let mut r = CoverageReport::sample();
        r.tool_coverage_pct = 50.0;
        r.arg_coverage_pct = 30.0;
        let t = Thresholds::parse("tools=80,args=60").unwrap();
        let unmet = t.evaluate(&r).unwrap_err();
        assert_eq!(unmet.len(), 2);
        assert_eq!(unmet[0].threshold.kind, ThresholdKind::Tools);
        assert_eq!(unmet[1].threshold.kind, ThresholdKind::Args);
    }

    #[test]
    fn unmet_display_includes_dimension_and_percentages() {
        let unmet = UnmetThreshold {
            threshold: Threshold {
                kind: ThresholdKind::Tools,
                min_pct: 80.0,
            },
            actual_pct: 50.0,
        };
        let s = unmet.to_string();
        assert!(s.contains("tools"));
        assert!(s.contains("50.0"));
        assert!(s.contains("80.0"));
    }

    #[test]
    fn duplicate_dimension_takes_last_value() {
        let t = Thresholds::parse("tools=50,tools=80").unwrap();
        assert_eq!(t.entries.len(), 1);
        assert_eq!(t.entries[0].min_pct, 80.0);
    }
}
