//! Joining the taxonomy against the probes a run exercised.
//!
//! [`coverage`] takes the observed probe tokens (which probes ran) and the
//! applicable suite scopes (server-only or also agent), then marks each fault
//! family covered, partially covered, uncovered, or not applicable. The result
//! is a plain serializable report so the CLI can render it as a table or JSON.

use std::collections::BTreeSet;

use serde::{Deserialize, Serialize};

use super::registry::FaultTaxonomy;

/// Coverage verdict for one fault family.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum CoverageStatus {
    /// Every leaf fault in the family was exercised by some probe.
    Covered,
    /// Some but not all leaf faults were exercised.
    Partial,
    /// The family applies to this suite but no leaf fault was exercised.
    Uncovered,
    /// The family does not apply to this suite shape (for example
    /// provider-integration faults for a suite with no agents).
    NotApplicable,
}

impl CoverageStatus {
    /// Stable lowercase label for reporters.
    pub fn label(self) -> &'static str {
        match self {
            CoverageStatus::Covered => "covered",
            CoverageStatus::Partial => "partial",
            CoverageStatus::Uncovered => "uncovered",
            CoverageStatus::NotApplicable => "n/a",
        }
    }
}

/// Per-leaf coverage detail.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FaultLeafCoverage {
    /// Leaf fault id.
    pub id: String,
    /// Leaf fault name.
    pub name: String,
    /// Whether any of the leaf's probes were observed.
    pub exercised: bool,
    /// The probes that matched (subset of the leaf's `probes`).
    pub matched_probes: Vec<String>,
}

/// Per-family coverage roll-up.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FamilyCoverage {
    /// Category id.
    pub id: String,
    /// Category name.
    pub name: String,
    /// Family verdict.
    pub status: CoverageStatus,
    /// Number of leaf faults exercised.
    pub exercised: usize,
    /// Total leaf faults in the family.
    pub total: usize,
    /// Per-leaf detail.
    pub faults: Vec<FaultLeafCoverage>,
}

/// The whole coverage report.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FaultCoverageReport {
    /// Families in taxonomy order.
    pub families: Vec<FamilyCoverage>,
    /// Exercised applicable leaf faults over all applicable leaf faults, as a
    /// percentage. 100.0 when nothing applies.
    pub coverage_pct: f64,
    /// Families whose status is `covered`.
    pub covered_families: usize,
    /// Families that apply to this suite (status is not `n/a`).
    pub applicable_families: usize,
}

/// Compute fault-family coverage for a run.
///
/// `observed` is the set of probe tokens the run exercised; `applicable_scopes`
/// is the set of suite shapes in play (`server`, optionally `agent`). A family
/// applies when its `applies_to` intersects `applicable_scopes`.
pub fn coverage(
    taxonomy: &FaultTaxonomy,
    observed: &BTreeSet<String>,
    applicable_scopes: &BTreeSet<String>,
) -> FaultCoverageReport {
    let mut families = Vec::with_capacity(taxonomy.categories.len());
    let mut applicable_total = 0usize;
    let mut applicable_exercised = 0usize;
    let mut covered_families = 0usize;
    let mut applicable_families = 0usize;

    for category in &taxonomy.categories {
        let applies = category
            .applies_to
            .iter()
            .any(|s| applicable_scopes.contains(s));
        let leaves = leaf_coverage(taxonomy, &category.id, observed);
        let total = leaves.len();
        let exercised = leaves.iter().filter(|l| l.exercised).count();

        let status = if !applies || total == 0 {
            CoverageStatus::NotApplicable
        } else if exercised == total {
            CoverageStatus::Covered
        } else if exercised > 0 {
            CoverageStatus::Partial
        } else {
            CoverageStatus::Uncovered
        };

        if status != CoverageStatus::NotApplicable {
            applicable_families += 1;
            applicable_total += total;
            applicable_exercised += exercised;
            if status == CoverageStatus::Covered {
                covered_families += 1;
            }
        }

        families.push(FamilyCoverage {
            id: category.id.clone(),
            name: category.name.clone(),
            status,
            exercised,
            total,
            faults: leaves,
        });
    }

    let coverage_pct = if applicable_total == 0 {
        100.0
    } else {
        (applicable_exercised as f64 / applicable_total as f64) * 100.0
    };

    FaultCoverageReport {
        families,
        coverage_pct,
        covered_families,
        applicable_families,
    }
}

/// Build the per-leaf coverage for one category.
fn leaf_coverage(
    taxonomy: &FaultTaxonomy,
    category: &str,
    observed: &BTreeSet<String>,
) -> Vec<FaultLeafCoverage> {
    taxonomy
        .faults_in(category)
        .into_iter()
        .map(|fault| {
            let matched: Vec<String> = fault
                .probes
                .iter()
                .filter(|p| observed.contains(*p))
                .cloned()
                .collect();
            FaultLeafCoverage {
                id: fault.id.clone(),
                name: fault.name.clone(),
                exercised: !matched.is_empty(),
                matched_probes: matched,
            }
        })
        .collect()
}

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

    fn taxonomy() -> FaultTaxonomy {
        let yaml = r#"
version: 1
categories:
  - { id: PROTO, name: "Protocol", description: d, applies_to: [server, agent] }
  - { id: PROVIDER, name: "Provider", description: d, applies_to: [agent] }
faults:
  - { id: FAULT-PROTO-A, name: a, category: PROTO, description: d, probes: [negative-path] }
  - { id: FAULT-PROTO-B, name: b, category: PROTO, description: d, probes: [fuzz] }
  - { id: FAULT-PROVIDER-A, name: a, category: PROVIDER, description: d, probes: [agent] }
"#;
        FaultTaxonomy::from_yaml(yaml).unwrap()
    }

    fn set(items: &[&str]) -> BTreeSet<String> {
        items.iter().map(|s| s.to_string()).collect()
    }

    #[test]
    fn partial_when_only_some_leaves_are_exercised() {
        let tax = taxonomy();
        let report = coverage(&tax, &set(&["negative-path"]), &set(&["server", "agent"]));
        let proto = report.families.iter().find(|f| f.id == "PROTO").unwrap();
        assert_eq!(proto.status, CoverageStatus::Partial);
        assert_eq!(proto.exercised, 1);
        assert_eq!(proto.total, 2);
    }

    #[test]
    fn covered_when_every_leaf_is_exercised() {
        let tax = taxonomy();
        let report = coverage(&tax, &set(&["negative-path", "fuzz"]), &set(&["server"]));
        let proto = report.families.iter().find(|f| f.id == "PROTO").unwrap();
        assert_eq!(proto.status, CoverageStatus::Covered);
    }

    #[test]
    fn provider_family_is_not_applicable_without_agents() {
        let tax = taxonomy();
        let report = coverage(&tax, &set(&["negative-path"]), &set(&["server"]));
        let provider = report.families.iter().find(|f| f.id == "PROVIDER").unwrap();
        assert_eq!(provider.status, CoverageStatus::NotApplicable);
        // The n/a family is excluded from the applicable denominator.
        assert_eq!(report.applicable_families, 1);
    }

    #[test]
    fn coverage_pct_counts_only_applicable_leaves() {
        let tax = taxonomy();
        // server scope: PROTO applies (2 leaves), PROVIDER is n/a. One of two
        // PROTO leaves exercised -> 50%.
        let report = coverage(&tax, &set(&["negative-path"]), &set(&["server"]));
        assert!((report.coverage_pct - 50.0).abs() < f64::EPSILON);
    }

    #[test]
    fn uncovered_when_no_probe_matches() {
        let tax = taxonomy();
        let report = coverage(&tax, &set(&["security"]), &set(&["server", "agent"]));
        let proto = report.families.iter().find(|f| f.id == "PROTO").unwrap();
        assert_eq!(proto.status, CoverageStatus::Uncovered);
    }
}
