//! Weighted combined-score model for a test.
//!
//! A test's pass/fail can be gated two ways. Without a `threshold:`, the
//! legacy rule holds: every assertion and assert-set must pass. With a
//! `threshold:`, the executor computes a combined score (the weighted
//! pass-fraction across assertions and assert-sets) and passes the test
//! when the score is at or above the threshold. This module owns the
//! arithmetic so the executor only has to feed it per-item pass/fail
//! booleans; it never touches matchers or transports.
//!
//! The same primitives drive `derivedMetrics`: a derived metric is a
//! weighted aggregation over named assertion and assert-set scores. The
//! `value` is deliberately not an expression language (see
//! [`crate::suite::DerivedAggregation`]); it is a list of `{ref, weight}`
//! terms combined by weighted sum or average.

use std::collections::BTreeMap;

use crate::suite::{DerivedKind, DerivedMetric};

/// Pass/fail plus weight of one assertion or assert-set after evaluation.
///
/// The executor builds one of these per assertion (and per assert-set,
/// folding the set's internal threshold into a single `passed`) and
/// hands the list to [`combined_score`] and [`derived_metric_value`].
#[derive(Debug, Clone, PartialEq)]
pub struct ScoredItem {
    /// Optional name; only named items can be referenced by a derived
    /// metric.
    pub name: Option<String>,
    /// Weight this item contributes to the combined score.
    pub weight: f64,
    /// Whether the item passed (a passing item contributes its full
    /// weight; a failing item contributes zero).
    pub passed: bool,
}

/// Compute the weighted pass-fraction across `items`.
///
/// Returns `sum(weight_i for passing items) / sum(weight_i)`. With every
/// weight equal to the default `1.0` this is just "fraction of items that
/// passed." Returns `1.0` for an empty list so a test with no assertions
/// and a threshold still passes (an empty suite is vacuously satisfied,
/// matching the legacy "no expectations means just succeed" rule).
pub fn combined_score(items: &[ScoredItem]) -> f64 {
    let total: f64 = items.iter().map(|i| i.weight).sum();
    if total <= 0.0 {
        return 1.0;
    }
    let earned: f64 = items.iter().filter(|i| i.passed).map(|i| i.weight).sum();
    earned / total
}

/// Decide whether a test passes given its scored items and an optional
/// threshold.
///
/// `None` threshold preserves the legacy contract exactly: the test
/// passes only when every item passed. `Some(t)` switches to score
/// gating: the test passes when [`combined_score`] is at or above `t`.
pub fn test_passes(items: &[ScoredItem], threshold: Option<f64>) -> bool {
    match threshold {
        None => items.iter().all(|i| i.passed),
        Some(t) => combined_score(items) >= t,
    }
}

/// Resolve every named item into a `name -> score` map for derived-metric
/// lookups. A passing item scores `1.0`, a failing item `0.0`. Unnamed
/// items are skipped because a derived metric can only reference a name.
pub fn named_scores(items: &[ScoredItem]) -> BTreeMap<String, f64> {
    let mut out = BTreeMap::new();
    for item in items {
        if let Some(name) = &item.name {
            out.insert(name.clone(), if item.passed { 1.0 } else { 0.0 });
        }
    }
    out
}

/// One computed derived metric ready for the report.
#[derive(Debug, Clone, PartialEq)]
pub struct DerivedMetricResult {
    /// Metric name, copied from the declaration.
    pub name: String,
    /// The aggregated numeric value.
    pub value: f64,
    /// `Some(true)`/`Some(false)` when the metric carries a threshold and
    /// gates the test; `None` when it is reported only.
    pub passed: Option<bool>,
    /// Names referenced by the aggregation that did not resolve to a
    /// known score. Reported so a typo in a `ref:` is visible rather
    /// than silently scoring zero.
    pub unresolved_refs: Vec<String>,
}

/// Evaluate one derived metric against a resolved `name -> score` map.
///
/// A referenced name that is not present scores `0.0` and is recorded in
/// [`DerivedMetricResult::unresolved_refs`] so the reporter can flag the
/// typo. When the metric carries a `threshold`, the result's `passed`
/// field is `Some(value >= threshold)`.
pub fn derived_metric_value(
    metric: &DerivedMetric,
    scores: &BTreeMap<String, f64>,
) -> DerivedMetricResult {
    let mut weighted_sum = 0.0;
    let mut weight_total = 0.0;
    let mut unresolved = Vec::new();
    for term in &metric.value.terms {
        let score = match scores.get(&term.reference) {
            Some(s) => *s,
            None => {
                unresolved.push(term.reference.clone());
                0.0
            }
        };
        weighted_sum += score * term.weight;
        weight_total += term.weight;
    }
    let value = match metric.value.kind {
        DerivedKind::WeightedSum => weighted_sum,
        DerivedKind::WeightedAverage => {
            if weight_total == 0.0 {
                0.0
            } else {
                weighted_sum / weight_total
            }
        }
    };
    let passed = metric.threshold.map(|t| value >= t);
    DerivedMetricResult {
        name: metric.name.clone(),
        value,
        passed,
        unresolved_refs: unresolved,
    }
}

/// Evaluate every derived metric in declaration order, feeding each result's
/// value back into the score map so a later metric can reference an earlier one
/// by name (chained derivation). `base` is the named assertion and assert-set
/// scores from [`named_scores`]. A forward or self reference is rejected at
/// parse time (see the suite parser), so here an unresolved name simply scores
/// `0.0` and is reported in [`DerivedMetricResult::unresolved_refs`] as before.
pub fn derive_all(
    metrics: &[DerivedMetric],
    base: &BTreeMap<String, f64>,
) -> Vec<DerivedMetricResult> {
    let mut scores = base.clone();
    let mut out = Vec::with_capacity(metrics.len());
    for metric in metrics {
        let result = derived_metric_value(metric, &scores);
        // Make this metric visible to later metrics by name.
        scores.insert(metric.name.clone(), result.value);
        out.push(result);
    }
    out
}

/// Find the first derived-metric term that references a derived metric declared
/// later or itself. Derived metrics evaluate in declaration order
/// ([`derive_all`]), so such a reference can never resolve. Returns
/// `Some((metric_name, reference))` for the offender, or `None` when every
/// reference points at an earlier derived metric, a named assertion, or an
/// assert-set. The suite parser maps the `Some` case to a parse error.
pub fn forward_or_self_ref(metrics: &[DerivedMetric]) -> Option<(String, String)> {
    let index: BTreeMap<&str, usize> = metrics
        .iter()
        .enumerate()
        .map(|(i, m)| (m.name.as_str(), i))
        .collect();
    for (i, metric) in metrics.iter().enumerate() {
        for term in &metric.value.terms {
            if index.get(term.reference.as_str()).is_some_and(|&j| j >= i) {
                return Some((metric.name.clone(), term.reference.clone()));
            }
        }
    }
    None
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::suite::{DerivedAggregation, DerivedTerm};

    fn item(name: Option<&str>, weight: f64, passed: bool) -> ScoredItem {
        ScoredItem {
            name: name.map(str::to_string),
            weight,
            passed,
        }
    }

    #[test]
    fn empty_items_score_one_and_pass() {
        assert_eq!(combined_score(&[]), 1.0);
        assert!(test_passes(&[], Some(0.5)));
        assert!(test_passes(&[], None));
    }

    #[test]
    fn equal_weights_give_simple_pass_fraction() {
        let items = vec![
            item(None, 1.0, true),
            item(None, 1.0, true),
            item(None, 1.0, false),
            item(None, 1.0, false),
        ];
        assert!((combined_score(&items) - 0.5).abs() < f64::EPSILON);
    }

    #[test]
    fn weights_bias_the_score() {
        // A heavy passing item plus a light failing item scores high.
        let items = vec![item(None, 3.0, true), item(None, 1.0, false)];
        assert!((combined_score(&items) - 0.75).abs() < f64::EPSILON);
    }

    #[test]
    fn no_threshold_requires_all_to_pass() {
        let all_pass = vec![item(None, 1.0, true), item(None, 1.0, true)];
        assert!(test_passes(&all_pass, None));
        let one_fail = vec![item(None, 1.0, true), item(None, 1.0, false)];
        assert!(!test_passes(&one_fail, None));
    }

    #[test]
    fn threshold_gates_at_the_boundary() {
        // Exactly 0.5 passes a 0.5 threshold (>= is inclusive).
        let items = vec![item(None, 1.0, true), item(None, 1.0, false)];
        assert!(test_passes(&items, Some(0.5)));
        // Just above the score fails.
        assert!(!test_passes(&items, Some(0.51)));
    }

    #[test]
    fn named_scores_map_pass_to_one_fail_to_zero() {
        let items = vec![item(Some("a"), 1.0, true), item(Some("b"), 1.0, false)];
        let scores = named_scores(&items);
        assert_eq!(scores.get("a"), Some(&1.0));
        assert_eq!(scores.get("b"), Some(&0.0));
    }

    #[test]
    fn derived_weighted_sum_adds_terms() {
        let mut scores = BTreeMap::new();
        scores.insert("a".to_string(), 1.0);
        scores.insert("b".to_string(), 1.0);
        let metric = DerivedMetric {
            name: "blended".into(),
            value: DerivedAggregation {
                kind: DerivedKind::WeightedSum,
                terms: vec![
                    DerivedTerm {
                        reference: "a".into(),
                        weight: 2.0,
                    },
                    DerivedTerm {
                        reference: "b".into(),
                        weight: 0.5,
                    },
                ],
            },
            threshold: None,
        };
        let result = derived_metric_value(&metric, &scores);
        assert!((result.value - 2.5).abs() < f64::EPSILON);
        assert!(result.passed.is_none());
        assert!(result.unresolved_refs.is_empty());
    }

    #[test]
    fn derived_weighted_average_normalizes_by_weight() {
        let mut scores = BTreeMap::new();
        scores.insert("a".to_string(), 1.0);
        scores.insert("b".to_string(), 0.0);
        let metric = DerivedMetric {
            name: "avg".into(),
            value: DerivedAggregation {
                kind: DerivedKind::WeightedAverage,
                terms: vec![
                    DerivedTerm {
                        reference: "a".into(),
                        weight: 3.0,
                    },
                    DerivedTerm {
                        reference: "b".into(),
                        weight: 1.0,
                    },
                ],
            },
            threshold: Some(0.7),
        };
        let result = derived_metric_value(&metric, &scores);
        // (1*3 + 0*1) / (3+1) = 0.75
        assert!((result.value - 0.75).abs() < f64::EPSILON);
        assert_eq!(result.passed, Some(true));
    }

    #[test]
    fn derive_all_chains_an_earlier_metric_into_a_later_one() {
        let mut base = BTreeMap::new();
        base.insert("a".to_string(), 1.0);
        base.insert("b".to_string(), 0.0);
        // m1 = avg(a, b) = 0.5; m2 = sum(m1 * 2) = 1.0, referencing the
        // earlier derived metric by name.
        let m1 = DerivedMetric {
            name: "m1".into(),
            value: DerivedAggregation {
                kind: DerivedKind::WeightedAverage,
                terms: vec![
                    DerivedTerm {
                        reference: "a".into(),
                        weight: 1.0,
                    },
                    DerivedTerm {
                        reference: "b".into(),
                        weight: 1.0,
                    },
                ],
            },
            threshold: None,
        };
        let m2 = DerivedMetric {
            name: "m2".into(),
            value: DerivedAggregation {
                kind: DerivedKind::WeightedSum,
                terms: vec![DerivedTerm {
                    reference: "m1".into(),
                    weight: 2.0,
                }],
            },
            threshold: None,
        };
        let out = derive_all(&[m1, m2], &base);
        assert_eq!(out.len(), 2);
        assert!((out[0].value - 0.5).abs() < f64::EPSILON);
        assert!((out[1].value - 1.0).abs() < f64::EPSILON, "m1 fed into m2");
        assert!(out[1].unresolved_refs.is_empty(), "m1 resolved for m2");
    }

    #[test]
    fn forward_or_self_ref_catches_self_and_later_but_allows_earlier() {
        let sum = |name: &str, reference: &str| DerivedMetric {
            name: name.into(),
            value: DerivedAggregation {
                kind: DerivedKind::WeightedSum,
                terms: vec![DerivedTerm {
                    reference: reference.into(),
                    weight: 1.0,
                }],
            },
            threshold: None,
        };
        // Self reference: rejected.
        assert!(forward_or_self_ref(&[sum("m", "m")]).is_some());
        // Forward reference (m1 -> m2 declared later): rejected.
        let fwd = forward_or_self_ref(&[sum("m1", "m2"), sum("m2", "a")]);
        assert_eq!(fwd, Some(("m1".to_string(), "m2".to_string())));
        // Backward reference (m2 -> m1) and a reference to a non-derived name
        // (an assertion) are both fine.
        assert!(forward_or_self_ref(&[sum("m1", "a"), sum("m2", "m1")]).is_none());
    }

    #[test]
    fn derived_unknown_ref_scores_zero_and_is_recorded() {
        let scores = BTreeMap::new();
        let metric = DerivedMetric {
            name: "typo".into(),
            value: DerivedAggregation {
                kind: DerivedKind::WeightedSum,
                terms: vec![DerivedTerm {
                    reference: "missing".into(),
                    weight: 1.0,
                }],
            },
            threshold: None,
        };
        let result = derived_metric_value(&metric, &scores);
        assert_eq!(result.value, 0.0);
        assert_eq!(result.unresolved_refs, vec!["missing".to_string()]);
    }
}
