//! Handler for `mcptest coverage`.
//!
//! Computes suite coverage against a declared tool catalog and, with
//! `--suggest`, emits draft tests for the gaps. The analysis is static:
//! the suite YAML says which tools each test calls (and with which
//! arguments, and which error fixtures it injects), and the catalog file
//! says what the server declares. Reading the catalog from a file keeps
//! the command deterministic and CI-friendly, the same convention
//! `generate suite` uses for scaffolding.

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

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

use mcptest_core::coverage::{
    build_suggestions, CoverageReport, CoverageTracker, Observation, SuggestionContext, Suggestions,
};

use crate::cli::args::{CoverageArgs, CoverageFormat};

/// What the suite YAML contributes to the coverage computation: the
/// observations (one per tool test), the declared error fixtures, the
/// transports its servers speak, and the names drafts must not reuse.
struct SuiteAnalysis {
    observations: Vec<Observation>,
    test_names: BTreeSet<String>,
    declared_error_paths: Vec<String>,
    error_path_tools: BTreeMap<String, String>,
    declared_transports: Vec<String>,
    first_server: Option<String>,
}

/// Analyze the suite, print the report, optionally print suggestions, and
/// gate on `--threshold`. Returns 0 on success, 2 on a usage or parse
/// error, 6 when the threshold gate fails.
pub(crate) fn coverage_command(args: &CoverageArgs) -> Result<i32> {
    let suite_path = match args.suite.clone() {
        Some(path) => path,
        // WOR-1350: shared default resolution (`mcptest.yaml`, then
        // `mcptest.yml`) so coverage analyzes the same file run would.
        None => {
            let cwd = std::env::current_dir()?;
            match crate::cli::default_config::resolve_default_config(&cwd) {
                Ok(resolved) => resolved.into_path(),
                Err(ambiguous) => {
                    eprintln!("mcptest coverage: {}", ambiguous.message());
                    return Ok(2);
                }
            }
        }
    };
    if !suite_path.exists() {
        println!(
            "mcptest coverage: no suite at {}; nothing to analyze",
            suite_path.display()
        );
        return Ok(0);
    }
    let Some(tools_from) = &args.tools_from else {
        eprintln!(
            "mcptest coverage needs a tool catalog to measure against; pass \
             --tools-from <tools/list snapshot or mock manifest>."
        );
        return Ok(2);
    };
    let catalog = match read_catalog(tools_from) {
        Ok(tools) => tools,
        Err(err) => {
            eprintln!("mcptest coverage: {err:#}");
            return Ok(2);
        }
    };
    let analysis = match analyze_suite(&suite_path) {
        Ok(a) => a,
        Err(err) => {
            eprintln!("mcptest coverage: {err:#}");
            return Ok(2);
        }
    };
    let report = build_report(&analysis, &catalog);
    let suggestions = args
        .suggest
        .then(|| suggest_for(&report, &catalog, &analysis, args));
    render(args, &suite_path, &report, suggestions.as_ref())?;
    if let Some(threshold) = args.threshold {
        if report.tool_coverage_pct < threshold {
            eprintln!(
                "coverage threshold not met: tool coverage {:.0}% is below {threshold:.0}%",
                report.tool_coverage_pct
            );
            return Ok(6);
        }
    }
    Ok(0)
}

/// Build the suggestion set for the gaps in `report`. Existing names are
/// the suite's test names plus the names in the latest persisted run (if
/// any), so a draft never collides with a test the operator already has.
fn suggest_for(
    report: &CoverageReport,
    catalog: &[Value],
    analysis: &SuiteAnalysis,
    args: &CoverageArgs,
) -> Suggestions {
    let mut existing = analysis.test_names.clone();
    existing.extend(latest_run_test_names(Path::new(".")));
    let server_name = args
        .server
        .clone()
        .or_else(|| analysis.first_server.clone())
        .unwrap_or_else(|| "server".to_string());
    let ctx = SuggestionContext {
        server_name: &server_name,
        existing_test_names: &existing,
        error_path_tools: &analysis.error_path_tools,
    };
    build_suggestions(report, catalog, &ctx)
}

/// Fold the suite's observations against the catalog into a report.
fn build_report(analysis: &SuiteAnalysis, catalog: &[Value]) -> CoverageReport {
    let mut tracker = CoverageTracker::new();
    for obs in &analysis.observations {
        tracker.record(obs);
    }
    let declared_tools: Vec<String> = catalog
        .iter()
        .filter_map(|t| t.get("name").and_then(Value::as_str))
        .map(str::to_owned)
        .collect();
    let universe = pairwise_universe(catalog);
    tracker.into_report(
        &declared_tools,
        &analysis.declared_error_paths,
        &analysis.declared_transports,
        Some(universe),
    )
}

/// Pairwise argument universe across the catalog: `n*(n-1)/2` per tool
/// with `n` declared properties, one "self" combination for single-arg
/// tools, mirroring how the tracker counts observed combinations.
fn pairwise_universe(catalog: &[Value]) -> usize {
    catalog
        .iter()
        .map(|tool| {
            let schema = tool.get("inputSchema").or_else(|| tool.get("input_schema"));
            let n = schema
                .and_then(|s| s.get("properties"))
                .and_then(Value::as_object)
                .map(|p| p.len())
                .unwrap_or(0);
            match n {
                0 => 0,
                1 => 1,
                _ => n * (n - 1) / 2,
            }
        })
        .sum()
}

/// Read the tool catalog from a `tools/list` JSON snapshot, a bare JSON
/// tools array, or a YAML mock manifest, the same shapes `generate suite`
/// accepts.
fn read_catalog(path: &Path) -> Result<Vec<Value>> {
    let body = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read tool catalog {}", path.display()))?;
    let ext = path
        .extension()
        .and_then(|s| s.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();
    let value: Value = match ext.as_str() {
        "yml" | "yaml" => {
            let yaml: serde_yaml::Value = serde_yaml::from_str(&body)
                .with_context(|| format!("failed to parse YAML {}", path.display()))?;
            serde_json::to_value(yaml).context("YAML value converts to JSON")?
        }
        _ => serde_json::from_str(&body)
            .with_context(|| format!("failed to parse JSON {}", path.display()))?,
    };
    extract_tools(&value).ok_or_else(|| {
        anyhow!(
            "{} has no tools: expected `tools[]`, a tools array, or `mock_server.tools[]`",
            path.display()
        )
    })
}

/// Pull a tools array out of any of the accepted container shapes.
fn extract_tools(value: &Value) -> Option<Vec<Value>> {
    if let Some(arr) = value.as_array() {
        return Some(arr.clone());
    }
    if let Some(arr) = value.get("tools").and_then(Value::as_array) {
        return Some(arr.clone());
    }
    value
        .get("mock_server")
        .and_then(|m| m.get("tools"))
        .and_then(Value::as_array)
        .cloned()
}

/// Parse the suite and extract everything coverage needs from it.
fn analyze_suite(path: &Path) -> Result<SuiteAnalysis> {
    let body = std::fs::read_to_string(path)
        .with_context(|| format!("failed to read suite {}", path.display()))?;
    let suite: serde_yaml::Value = serde_yaml::from_str(&body)
        .with_context(|| format!("failed to parse suite {}", path.display()))?;
    let (transports_by_server, declared_transports, first_server) = server_transports(&suite);
    let (declared_error_paths, error_path_tools) = error_fixtures(&suite);
    let mut observations = Vec::new();
    let mut test_names = BTreeSet::new();
    for test in suite
        .get("tools")
        .and_then(serde_yaml::Value::as_sequence)
        .into_iter()
        .flatten()
    {
        if let Some(name) = test.get("name").and_then(serde_yaml::Value::as_str) {
            test_names.insert(name.to_owned());
        }
        let Some(tool) = test.get("tool").and_then(serde_yaml::Value::as_str) else {
            continue;
        };
        observations.push(Observation {
            tool: tool.to_owned(),
            args: yaml_to_json(test.get("args")),
            error_path: test
                .get("inject_error")
                .and_then(serde_yaml::Value::as_str)
                .map(str::to_owned),
            transport: test
                .get("server")
                .and_then(serde_yaml::Value::as_str)
                .and_then(|s| transports_by_server.get(s).cloned())
                .unwrap_or_else(|| "stdio".to_string()),
        });
    }
    Ok(SuiteAnalysis {
        observations,
        test_names,
        declared_error_paths,
        error_path_tools,
        declared_transports,
        first_server,
    })
}

/// Map each declared server to its transport (`command:` means stdio,
/// `url:` means http), plus the deduplicated transport list in
/// declaration order and the first server key (the default draft target).
fn server_transports(
    suite: &serde_yaml::Value,
) -> (BTreeMap<String, String>, Vec<String>, Option<String>) {
    let mut by_server = BTreeMap::new();
    let mut declared = Vec::new();
    let mut first = None;
    for (key, spec) in suite
        .get("servers")
        .and_then(serde_yaml::Value::as_mapping)
        .into_iter()
        .flatten()
    {
        let Some(name) = key.as_str() else { continue };
        if first.is_none() {
            first = Some(name.to_owned());
        }
        let transport = if spec.get("url").is_some() {
            "http"
        } else {
            "stdio"
        };
        by_server.insert(name.to_owned(), transport.to_owned());
        if !declared.iter().any(|t| t == transport) {
            declared.push(transport.to_owned());
        }
    }
    (by_server, declared, first)
}

/// Declared error fixtures: every `fixtures.errors[].name`, plus the
/// name-to-tool map for fixtures scoped to a single tool.
fn error_fixtures(suite: &serde_yaml::Value) -> (Vec<String>, BTreeMap<String, String>) {
    let mut names = Vec::new();
    let mut tools = BTreeMap::new();
    for fixture in suite
        .get("fixtures")
        .and_then(|f| f.get("errors"))
        .and_then(serde_yaml::Value::as_sequence)
        .into_iter()
        .flatten()
    {
        let Some(name) = fixture.get("name").and_then(serde_yaml::Value::as_str) else {
            continue;
        };
        names.push(name.to_owned());
        if let Some(tool) = fixture.get("tool").and_then(serde_yaml::Value::as_str) {
            tools.insert(name.to_owned(), tool.to_owned());
        }
    }
    (names, tools)
}

/// Convert a YAML node to JSON. Missing or non-convertible args collapse
/// to an empty object, which the tracker treats as a no-arg call.
fn yaml_to_json(node: Option<&serde_yaml::Value>) -> Value {
    node.and_then(|v| serde_json::to_value(v).ok())
        .filter(Value::is_object)
        .unwrap_or_else(|| serde_json::json!({}))
}

/// Test names in the newest persisted run under `<root>/.mcptest/runs`,
/// so drafts also avoid names the operator already ran. Empty when no
/// run store exists; reading it is best-effort by design.
fn latest_run_test_names(root: &Path) -> BTreeSet<String> {
    let dir = root.join(".mcptest").join("runs");
    let Ok(entries) = std::fs::read_dir(&dir) else {
        return BTreeSet::new();
    };
    let newest = entries
        .flatten()
        .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
        .filter_map(|e| Some((e.metadata().ok()?.modified().ok()?, e.path())))
        .max_by_key(|(mtime, _)| *mtime);
    let Some((_, path)) = newest else {
        return BTreeSet::new();
    };
    std::fs::read_to_string(path)
        .ok()
        .and_then(|body| serde_json::from_str::<Value>(&body).ok())
        .and_then(|run| {
            let tests = run.get("tests")?.as_array()?.clone();
            Some(
                tests
                    .iter()
                    .filter_map(|t| t.get("name").and_then(Value::as_str))
                    .map(str::to_owned)
                    .collect(),
            )
        })
        .unwrap_or_default()
}

/// Print the report (and suggestions) in the requested format.
fn render(
    args: &CoverageArgs,
    suite_path: &Path,
    report: &CoverageReport,
    suggestions: Option<&Suggestions>,
) -> Result<()> {
    match args.format {
        CoverageFormat::Json => {
            let mut value = serde_json::to_value(report)?;
            if let (Some(s), Some(obj)) = (suggestions, value.as_object_mut()) {
                obj.insert("suggestions".to_string(), s.to_json());
            }
            println!("{}", serde_json::to_string_pretty(&value)?);
        }
        CoverageFormat::Pretty => {
            print_pretty(suite_path, report);
            if let Some(s) = suggestions {
                print_suggestions(s);
            }
        }
    }
    Ok(())
}

/// The human-friendly table: one row per declared tool, the uncovered
/// error fixtures, then the percentage one-liner the reporters share.
fn print_pretty(suite_path: &Path, report: &CoverageReport) {
    println!(
        "coverage: {} against {} declared tool(s)",
        suite_path.display(),
        report.tools.len()
    );
    println!();
    println!(
        "{:<28} {:>6} {:>6} {:>11} {:>12}",
        "TOOL", "CALLED", "CALLS", "ARG COMBOS", "ERROR PATHS"
    );
    for tool in &report.tools {
        println!(
            "{:<28} {:>6} {:>6} {:>11} {:>12}",
            tool.name,
            if tool.called { "yes" } else { "no" },
            tool.call_count,
            tool.arg_combinations_seen.len(),
            tool.error_paths_seen.len(),
        );
    }
    for error_path in report.error_paths.iter().filter(|e| !e.triggered) {
        println!("error path not triggered: {}", error_path.name);
    }
    println!();
    println!("{}", report.pretty_one_liner());
}

/// Print the combined draft block after the table, or say plainly that
/// the surface is fully covered.
fn print_suggestions(suggestions: &Suggestions) {
    println!();
    if suggestions.is_empty() {
        println!("# no suggestions: the declared surface is fully covered");
    } else {
        print!("{}", suggestions.combined_yaml());
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::io::Write as _;

    fn write_temp(contents: &str, ext: &str) -> tempfile::NamedTempFile {
        let mut file = tempfile::Builder::new().suffix(ext).tempfile().unwrap();
        file.write_all(contents.as_bytes()).unwrap();
        file
    }

    #[test]
    fn analyzes_tool_tests_into_observations_with_args_and_transport() {
        let suite = "\
servers:
  status:
    command: ['./srv']
  remote:
    url: https://example.test/mcp
tools:
  - name: a
    server: status
    tool: alpha
    args: { q: hi }
  - name: b
    server: remote
    tool: bravo
";
        let file = write_temp(suite, ".yml");
        let analysis = analyze_suite(file.path()).unwrap();
        assert_eq!(analysis.observations.len(), 2);
        assert_eq!(analysis.observations[0].tool, "alpha");
        assert_eq!(analysis.observations[0].args["q"], "hi");
        assert_eq!(analysis.observations[0].transport, "stdio");
        assert_eq!(analysis.observations[1].transport, "http");
        assert_eq!(
            analysis.declared_transports,
            vec!["stdio".to_string(), "http".to_string()]
        );
        assert_eq!(analysis.first_server.as_deref(), Some("status"));
    }

    #[test]
    fn collects_error_fixture_names_and_tool_scopes() {
        let suite = "\
servers:
  s:
    command: ['./srv']
fixtures:
  errors:
    - name: invalid_title
      code: -32000
      message: bad title
      tool: create_issue
    - name: rate_limited
      code: -32001
      message: slow down
      applies_to: any
tools: []
";
        let file = write_temp(suite, ".yml");
        let analysis = analyze_suite(file.path()).unwrap();
        assert_eq!(
            analysis.declared_error_paths,
            vec!["invalid_title".to_string(), "rate_limited".to_string()]
        );
        assert_eq!(
            analysis.error_path_tools.get("invalid_title").unwrap(),
            "create_issue"
        );
        assert!(!analysis.error_path_tools.contains_key("rate_limited"));
    }

    #[test]
    fn injected_error_counts_as_a_triggered_path() {
        let suite = "\
servers:
  s:
    command: ['./srv']
tools:
  - name: t
    server: s
    tool: alpha
    inject_error: rate_limited
";
        let file = write_temp(suite, ".yml");
        let analysis = analyze_suite(file.path()).unwrap();
        assert_eq!(
            analysis.observations[0].error_path.as_deref(),
            Some("rate_limited")
        );
    }

    #[test]
    fn reads_catalog_from_mock_manifest_and_snapshot_shapes() {
        let manifest = write_temp("mock_server:\n  tools:\n    - name: alpha\n", ".yaml");
        let tools = read_catalog(manifest.path()).unwrap();
        assert_eq!(tools[0]["name"], "alpha");

        let snapshot = write_temp(r#"{"tools":[{"name":"bravo"}]}"#, ".json");
        let tools = read_catalog(snapshot.path()).unwrap();
        assert_eq!(tools[0]["name"], "bravo");
    }

    #[test]
    fn pairwise_universe_counts_per_tool_property_pairs() {
        let catalog = vec![
            serde_json::json!({ "name": "none", "inputSchema": { "type": "object" } }),
            serde_json::json!({ "name": "one", "inputSchema": {
                "type": "object", "properties": { "a": {} } } }),
            serde_json::json!({ "name": "three", "input_schema": {
                "type": "object", "properties": { "a": {}, "b": {}, "c": {} } } }),
        ];
        // 0 + 1 (self) + 3 (pairs of three) = 4.
        assert_eq!(pairwise_universe(&catalog), 4);
    }

    #[test]
    fn missing_run_store_yields_no_extra_names() {
        let dir = tempfile::tempdir().unwrap();
        assert!(latest_run_test_names(dir.path()).is_empty());
    }
}
