//! Handler for `mcptest fault-coverage`.
//!
//! Joins the runtime fault taxonomy against the probes a suite exercises and
//! reports which fault families are covered. The suite scan is static: the YAML
//! says which probe-bearing features a suite uses (a `compliance:` block, a
//! `faults:`/`recovery:` pair, a `security:` block, agents, and so on). Probes
//! run out-of-band (a separate `mcptest fuzz` pass, say) are folded in with
//! `--probe`. Reading from the suite keeps the command deterministic and
//! CI-friendly, the same convention `mcptest coverage` uses.

use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use serde_yaml::Value;

use mcptest_core::runtime_faults::{coverage, FaultCoverageReport, FaultTaxonomy, KNOWN_PROBES};

use crate::cli::args::{FaultCoverageArgs, FaultCoverageFormat};
use crate::cli::exit_codes::{EXIT_COVERAGE_FLOOR, EXIT_INVALID_INVOCATION, EXIT_OK};

/// Load the taxonomy, analyze the suite, render the report, and gate on
/// `--threshold`. Returns 0 on success, 2 on a usage error, 6 when the
/// threshold gate fails.
pub(crate) fn fault_coverage_command(args: &FaultCoverageArgs) -> Result<i32> {
    let taxonomy = match &args.registry {
        Some(path) => FaultTaxonomy::load(path)
            .with_context(|| format!("loading fault taxonomy from {}", path.display()))?,
        None => FaultTaxonomy::embedded().context("loading the embedded fault taxonomy")?,
    };

    for probe in &args.probes {
        if !KNOWN_PROBES.contains(&probe.as_str()) {
            eprintln!(
                "mcptest fault-coverage: unknown probe token `{probe}` (known: {})",
                KNOWN_PROBES.join(", ")
            );
            return Ok(EXIT_INVALID_INVOCATION);
        }
    }

    let mut observed: BTreeSet<String> = args.probes.iter().cloned().collect();
    let mut scopes: BTreeSet<String> = BTreeSet::new();
    scopes.insert("server".to_string());

    match suite_to_analyze(args)? {
        Some(path) => {
            let (tokens, suite_scopes) = analyze_suite(&path)
                .with_context(|| format!("analyzing suite {}", path.display()))?;
            observed.extend(tokens);
            scopes.extend(suite_scopes);
        }
        // No suite in play: assume every scope applies so a family is never
        // spuriously reported not-applicable.
        None => {
            scopes.insert("agent".to_string());
        }
    }

    let report = coverage(&taxonomy, &observed, &scopes);
    match args.format {
        FaultCoverageFormat::Json => print_json(&report)?,
        FaultCoverageFormat::Pretty => print_pretty(&report),
    }

    if let Some(threshold) = args.threshold {
        if report.coverage_pct < threshold {
            eprintln!(
                "fault coverage {:.0}% is below the {threshold:.0}% threshold",
                report.coverage_pct
            );
            return Ok(EXIT_COVERAGE_FLOOR);
        }
    }
    Ok(EXIT_OK)
}

/// Resolve the suite to scan, or `None` when there is nothing to analyze
/// (`--no-suite`, or no default suite on disk).
fn suite_to_analyze(args: &FaultCoverageArgs) -> Result<Option<PathBuf>> {
    if args.no_suite {
        return Ok(None);
    }
    if let Some(path) = &args.suite {
        return Ok(path.exists().then(|| path.clone()));
    }
    let cwd = std::env::current_dir()?;
    match crate::cli::default_config::resolve_default_config(&cwd) {
        Ok(resolved) => {
            let path = resolved.into_path();
            Ok(path.exists().then_some(path))
        }
        // An ambiguous default is not fatal here: report the bare taxonomy.
        Err(_) => Ok(None),
    }
}

/// Detect the probe tokens and suite scopes a suite YAML implies.
fn analyze_suite(path: &Path) -> Result<(BTreeSet<String>, BTreeSet<String>)> {
    let raw = std::fs::read_to_string(path)?;
    let doc: Value = serde_yaml::from_str(&raw)?;
    let mut tokens: BTreeSet<String> = BTreeSet::new();
    let mut scopes: BTreeSet<String> = BTreeSet::new();
    scopes.insert("server".to_string());

    if has_key(&doc, "compliance") {
        tokens.insert("compliance".to_string());
        tokens.insert("conformance".to_string());
    }
    if has_key(&doc, "security") || contains_key(&doc, "trust_boundary") {
        tokens.insert("security".to_string());
    }
    if contains_key(&doc, "trust_boundary") {
        tokens.insert("trust-boundary".to_string());
    }
    if has_key(&doc, "faults") || contains_key(&doc, "recovery") || contains_key(&doc, "inject") {
        tokens.insert("fault-recovery".to_string());
    }
    if contains_key(&doc, "negative_path") {
        tokens.insert("negative-path".to_string());
    }
    if contains_key(&doc, "output_schema") {
        tokens.insert("output-schema".to_string());
    }
    if contains_key(&doc, "restart_policy") {
        tokens.insert("test-isolation".to_string());
    }
    if has_key(&doc, "agents") {
        scopes.insert("agent".to_string());
        tokens.insert("agent".to_string());
        for key in ["trajectory", "golden_path", "distractors", "equal_function"] {
            if contains_key(&doc, key) {
                tokens.insert("tool-selection".to_string());
            }
        }
    }
    Ok((tokens, scopes))
}

/// Whether a top-level mapping key is present.
fn has_key(doc: &Value, key: &str) -> bool {
    doc.as_mapping()
        .and_then(|m| m.get(Value::String(key.to_string())))
        .is_some()
}

/// Whether a mapping key appears anywhere in the document tree.
fn contains_key(value: &Value, key: &str) -> bool {
    match value {
        Value::Mapping(map) => map
            .iter()
            .any(|(k, v)| k.as_str().is_some_and(|s| s == key) || contains_key(v, key)),
        Value::Sequence(seq) => seq.iter().any(|v| contains_key(v, key)),
        _ => false,
    }
}

/// Render the report as one JSON object.
fn print_json(report: &FaultCoverageReport) -> Result<()> {
    println!("{}", serde_json::to_string_pretty(report)?);
    Ok(())
}

/// Render the report as a human-friendly table.
fn print_pretty(report: &FaultCoverageReport) {
    println!("Runtime fault family coverage");
    println!();
    for family in &report.families {
        println!(
            "  {:<5} {:<28} {}/{} faults  [{}]",
            family.status.label(),
            family.name,
            family.exercised,
            family.total,
            family.id,
        );
    }
    println!();
    println!(
        "  {:.0}% of applicable faults exercised; {} of {} families fully covered",
        report.coverage_pct, report.covered_families, report.applicable_families,
    );
}
