//! Coverage report types.
//!
//! `CoverageReport` carries the per-tool rollup the runner builds at the
//! end of a suite plus four run-level percentages (`tool_coverage_pct`,
//! `arg_coverage_pct`, `error_path_coverage_pct`, `transport_coverage_pct`)
//! that a CI gate matches against thresholds.
//!
//! Why a single struct rather than four parallel reports: a reviewer
//! reading a PR comment wants one line that answers "what is my coverage
//! like" without mentally joining four tables. Per-tool detail still
//! lives in `tools[]` for the drill-down view.
//!
//! ## Counting rule
//!
//! Every assertion against a tool counts toward tool coverage. Snapshot
//! tests count too, the same way an `exact` matcher would: each step that
//! talks to a tool registers one call. The decision is documented at the
//! crate level in `coverage::mod`.

use serde::{Deserialize, Serialize};
use std::collections::BTreeSet;

/// Aggregate coverage summary for a suite run.
///
/// Reporters consume this directly. Empty surfaces collapse cleanly: a
/// server with no error fixtures gives `error_path_coverage_pct = 100.0`
/// because there is nothing left to exercise.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CoverageReport {
    /// Per-tool coverage rows in declared order.
    pub tools: Vec<ToolCoverageEntry>,
    /// Percentage of declared tools the suite called at least once.
    pub tool_coverage_pct: f64,
    /// Percentage of pairwise argument combinations exercised across all
    /// tools. Pairwise depth keeps the denominator finite for tools with
    /// many boolean flags. Empty surface returns 100.0 by convention.
    pub arg_coverage_pct: f64,
    /// Percentage of declared error paths the suite triggered.
    pub error_path_coverage_pct: f64,
    /// Percentage of declared transports the suite exercised. Useful
    /// when a server speaks both stdio and HTTP; a single-transport
    /// server collapses to 100.0.
    pub transport_coverage_pct: f64,
    /// Transport-level rollup so reporters can list which transports
    /// were hit without re-walking the runner state.
    pub transports: Vec<TransportCoverageEntry>,
    /// Declared error paths from named-error fixtures plus the ones the
    /// suite actually triggered.
    pub error_paths: Vec<ErrorPathCoverageEntry>,
}

/// One row in [`CoverageReport::tools`].
///
/// `arg_combinations_seen` is the deduplicated list of argument shapes
/// the runner observed for this tool; `error_paths_seen` is the list of
/// error scenarios that fired against it. A CI reviewer drills in here
/// when the overall percentage tells them "which tool needs work".
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ToolCoverageEntry {
    /// Tool name as it appeared in `tools/list`.
    pub name: String,
    /// True when the suite called this tool at least once.
    pub called: bool,
    /// Total number of times the suite called this tool.
    pub call_count: usize,
    /// Distinct pairwise argument combinations the runner saw, in
    /// insertion order.
    pub arg_combinations_seen: Vec<ArgCombination>,
    /// Names of error fixtures that fired against this tool.
    pub error_paths_seen: Vec<String>,
}

/// One pairwise argument combination.
///
/// We cap at pairwise (2-way) depth because the full Cartesian product
/// explodes (10 boolean flags is 1024 combinations) and the empirical
/// research that backs pairwise testing shows the first two coordinates
/// catch the bulk of defects. Each combination is a sorted list of
/// (arg name, normalized value) pairs so two shapes that differ only in
/// JSON key order register as the same combination.
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ArgCombination {
    /// Sorted `(arg_name, normalized_value)` pairs.
    pub pairs: Vec<(String, String)>,
}

impl ArgCombination {
    /// Build a combination by sorting the pairs into canonical order.
    pub fn from_pairs(mut pairs: Vec<(String, String)>) -> Self {
        pairs.sort();
        pairs.dedup();
        Self { pairs }
    }
}

/// One row in [`CoverageReport::transports`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TransportCoverageEntry {
    /// Transport identifier (`stdio`, `http`, `sse`).
    pub name: String,
    /// True when the suite exercised this transport.
    pub exercised: bool,
}

/// One row in [`CoverageReport::error_paths`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ErrorPathCoverageEntry {
    /// Fixture name (matches the `name:` of a `fixtures.errors[]` entry).
    pub name: String,
    /// True when the suite triggered this error path at least once.
    pub triggered: bool,
}

impl CoverageReport {
    /// Empty report. Useful as a starting accumulator and for snapshot
    /// fixtures.
    pub fn empty() -> Self {
        Self {
            tools: Vec::new(),
            tool_coverage_pct: 100.0,
            arg_coverage_pct: 100.0,
            error_path_coverage_pct: 100.0,
            transport_coverage_pct: 100.0,
            transports: Vec::new(),
            error_paths: Vec::new(),
        }
    }

    /// Sample report used by reporter tests. Two tools, one called and
    /// one un-exercised, plus a stdio transport hit and a missed HTTP
    /// transport.
    pub fn sample() -> Self {
        Self {
            tools: vec![
                ToolCoverageEntry {
                    name: "create_issue".into(),
                    called: true,
                    call_count: 4,
                    arg_combinations_seen: vec![
                        ArgCombination::from_pairs(vec![
                            ("priority".into(), "high".into()),
                            ("public".into(), "true".into()),
                        ]),
                        ArgCombination::from_pairs(vec![
                            ("priority".into(), "low".into()),
                            ("public".into(), "false".into()),
                        ]),
                    ],
                    error_paths_seen: vec!["invalid_title".into()],
                },
                ToolCoverageEntry {
                    name: "list_issues".into(),
                    called: false,
                    call_count: 0,
                    arg_combinations_seen: Vec::new(),
                    error_paths_seen: Vec::new(),
                },
            ],
            tool_coverage_pct: 50.0,
            arg_coverage_pct: 42.0,
            error_path_coverage_pct: 50.0,
            transport_coverage_pct: 50.0,
            transports: vec![
                TransportCoverageEntry {
                    name: "stdio".into(),
                    exercised: true,
                },
                TransportCoverageEntry {
                    name: "http".into(),
                    exercised: false,
                },
            ],
            error_paths: vec![
                ErrorPathCoverageEntry {
                    name: "invalid_title".into(),
                    triggered: true,
                },
                ErrorPathCoverageEntry {
                    name: "rate_limited".into(),
                    triggered: false,
                },
            ],
        }
    }

    /// Render a one-line summary suitable for the pretty reporter's
    /// end-of-run block.
    pub fn pretty_one_liner(&self) -> String {
        format!(
            "{:.0}% tool coverage, {:.0}% arg coverage, {:.0}% error-path coverage",
            self.tool_coverage_pct, self.arg_coverage_pct, self.error_path_coverage_pct
        )
    }

    /// Render a GitHub-flavored Markdown table for PR comments.
    pub fn render_markdown(&self) -> String {
        let mut out = String::new();
        out.push_str("## Coverage\n\n");
        out.push_str(&format!(
            "Tool: {:.0}%, Args: {:.0}%, Error paths: {:.0}%, Transport: {:.0}%\n\n",
            self.tool_coverage_pct,
            self.arg_coverage_pct,
            self.error_path_coverage_pct,
            self.transport_coverage_pct,
        ));
        out.push_str("| Tool | Called | Calls | Arg combos | Error paths |\n");
        out.push_str("| --- | ---: | ---: | ---: | ---: |\n");
        for tool in &self.tools {
            out.push_str(&format!(
                "| `{}` | {} | {} | {} | {} |\n",
                tool.name,
                if tool.called { "yes" } else { "no" },
                tool.call_count,
                tool.arg_combinations_seen.len(),
                tool.error_paths_seen.len(),
            ));
        }
        out
    }

    /// Render a stand-alone HTML page so the future `mcptest coverage
    /// --output coverage.html` subcommand is a thin wrapper around the
    /// library. Inline CSS keeps the file self-contained.
    pub fn render_html(&self) -> String {
        let mut out = String::new();
        out.push_str("<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"utf-8\">");
        out.push_str("<title>mcptest coverage</title><style>");
        out.push_str(
            "body{font-family:system-ui,sans-serif;margin:2rem;color:#222}\
             h1{margin-top:0}\
             table{border-collapse:collapse;width:100%}\
             th,td{padding:.5rem .75rem;text-align:left;border-bottom:1px solid #ddd}\
             tr.uncovered{background:#fff4f4}\
             .pct{font-weight:600}",
        );
        out.push_str("</style></head><body>");
        out.push_str("<h1>mcptest coverage</h1>");
        out.push_str(&format!(
            "<p class=\"pct\">Tool: {:.0}% &middot; Args: {:.0}% &middot; \
             Error paths: {:.0}% &middot; Transport: {:.0}%</p>",
            self.tool_coverage_pct,
            self.arg_coverage_pct,
            self.error_path_coverage_pct,
            self.transport_coverage_pct,
        ));
        out.push_str("<table><thead><tr><th>Tool</th><th>Called</th><th>Calls</th>");
        out.push_str("<th>Arg combos</th><th>Error paths</th></tr></thead><tbody>");
        for tool in &self.tools {
            let row_class = if tool.called {
                ""
            } else {
                " class=\"uncovered\""
            };
            out.push_str(&format!(
                "<tr{}><td>{}</td><td>{}</td><td>{}</td><td>{}</td><td>{}</td></tr>",
                row_class,
                html_escape(&tool.name),
                if tool.called { "yes" } else { "no" },
                tool.call_count,
                tool.arg_combinations_seen.len(),
                tool.error_paths_seen.len(),
            ));
        }
        out.push_str("</tbody></table>");
        out.push_str("</body></html>");
        out
    }
}

/// Build a [`CoverageReport`] from raw observation maps.
///
/// `declared_tools`, `declared_error_paths`, and `declared_transports`
/// describe the server surface. The remaining maps record what the
/// suite actually hit. Tool order is preserved from `declared_tools` so
/// reporters do not have to sort separately.
#[allow(clippy::too_many_arguments)]
pub fn build(
    declared_tools: &[String],
    declared_error_paths: &[String],
    declared_transports: &[String],
    tool_calls: &std::collections::BTreeMap<String, usize>,
    tool_arg_combinations: &std::collections::BTreeMap<String, Vec<ArgCombination>>,
    tool_error_paths: &std::collections::BTreeMap<String, BTreeSet<String>>,
    error_paths_seen: &BTreeSet<String>,
    transports_seen: &BTreeSet<String>,
    arg_combination_universe_size: usize,
    arg_combinations_seen_total: usize,
) -> CoverageReport {
    let mut tools = Vec::with_capacity(declared_tools.len());
    for name in declared_tools {
        let call_count = tool_calls.get(name).copied().unwrap_or(0);
        let combos = tool_arg_combinations.get(name).cloned().unwrap_or_default();
        let errs = tool_error_paths
            .get(name)
            .map(|set| set.iter().cloned().collect::<Vec<_>>())
            .unwrap_or_default();
        tools.push(ToolCoverageEntry {
            name: name.clone(),
            called: call_count > 0,
            call_count,
            arg_combinations_seen: combos,
            error_paths_seen: errs,
        });
    }

    let tool_coverage_pct = percentage(
        tools.iter().filter(|t| t.called).count(),
        declared_tools.len(),
    );
    let arg_coverage_pct = percentage(arg_combinations_seen_total, arg_combination_universe_size);

    let error_paths: Vec<ErrorPathCoverageEntry> = declared_error_paths
        .iter()
        .map(|n| ErrorPathCoverageEntry {
            name: n.clone(),
            triggered: error_paths_seen.contains(n),
        })
        .collect();
    let error_path_coverage_pct = percentage(
        error_paths.iter().filter(|e| e.triggered).count(),
        declared_error_paths.len(),
    );

    let transports: Vec<TransportCoverageEntry> = declared_transports
        .iter()
        .map(|n| TransportCoverageEntry {
            name: n.clone(),
            exercised: transports_seen.contains(n),
        })
        .collect();
    let transport_coverage_pct = percentage(
        transports.iter().filter(|t| t.exercised).count(),
        declared_transports.len(),
    );

    CoverageReport {
        tools,
        tool_coverage_pct,
        arg_coverage_pct,
        error_path_coverage_pct,
        transport_coverage_pct,
        transports,
        error_paths,
    }
}

/// Hit rate as a percentage; empty surface returns 100.0 so a CI gate
/// does not fire on a server that declared nothing in the category.
fn percentage(hit: usize, total: usize) -> f64 {
    if total == 0 {
        100.0
    } else {
        (hit as f64 / total as f64) * 100.0
    }
}

fn html_escape(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    for ch in s.chars() {
        match ch {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(ch),
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::{BTreeMap, BTreeSet};

    #[test]
    fn empty_report_collapses_to_one_hundred_percent() {
        let r = CoverageReport::empty();
        assert!(r.tools.is_empty());
        assert_eq!(r.tool_coverage_pct, 100.0);
        assert_eq!(r.arg_coverage_pct, 100.0);
        assert_eq!(r.error_path_coverage_pct, 100.0);
        assert_eq!(r.transport_coverage_pct, 100.0);
    }

    #[test]
    fn build_rolls_up_per_tool_call_counts() {
        let declared = vec!["a".to_owned(), "b".to_owned(), "c".to_owned()];
        let mut calls = BTreeMap::new();
        calls.insert("a".into(), 2usize);
        calls.insert("c".into(), 1usize);
        let r = build(
            &declared,
            &[],
            &[],
            &calls,
            &BTreeMap::new(),
            &BTreeMap::new(),
            &BTreeSet::new(),
            &BTreeSet::new(),
            0,
            0,
        );
        assert_eq!(r.tools.len(), 3);
        assert!(r.tools[0].called);
        assert!(!r.tools[1].called);
        assert!(r.tools[2].called);
        assert!((r.tool_coverage_pct - 200.0 / 3.0).abs() < 1e-6);
    }

    #[test]
    fn build_attaches_arg_combinations_per_tool() {
        let declared = vec!["a".to_owned()];
        let combo = ArgCombination::from_pairs(vec![("k".into(), "v".into())]);
        let mut combos: BTreeMap<String, Vec<ArgCombination>> = BTreeMap::new();
        combos.insert("a".into(), vec![combo.clone()]);
        let mut calls = BTreeMap::new();
        calls.insert("a".into(), 1usize);
        let r = build(
            &declared,
            &[],
            &[],
            &calls,
            &combos,
            &BTreeMap::new(),
            &BTreeSet::new(),
            &BTreeSet::new(),
            1,
            1,
        );
        assert_eq!(r.tools[0].arg_combinations_seen, vec![combo]);
        assert_eq!(r.arg_coverage_pct, 100.0);
    }

    #[test]
    fn build_tracks_error_paths_against_declared_set() {
        let declared_err = vec!["bad_title".to_owned(), "rate_limit".to_owned()];
        let mut seen = BTreeSet::new();
        seen.insert("bad_title".to_owned());
        let r = build(
            &[],
            &declared_err,
            &[],
            &BTreeMap::new(),
            &BTreeMap::new(),
            &BTreeMap::new(),
            &seen,
            &BTreeSet::new(),
            0,
            0,
        );
        assert!(r.error_paths[0].triggered);
        assert!(!r.error_paths[1].triggered);
        assert_eq!(r.error_path_coverage_pct, 50.0);
    }

    #[test]
    fn arg_combination_sorts_pairs_into_canonical_order() {
        let a = ArgCombination::from_pairs(vec![
            ("zebra".into(), "1".into()),
            ("alpha".into(), "2".into()),
        ]);
        assert_eq!(a.pairs[0].0, "alpha");
        assert_eq!(a.pairs[1].0, "zebra");
    }

    #[test]
    fn report_renders_pretty_one_liner_with_three_percentages() {
        let r = CoverageReport::sample();
        let line = r.pretty_one_liner();
        assert!(line.contains("50% tool"));
        assert!(line.contains("42% arg"));
        assert!(line.contains("50% error-path"));
    }

    #[test]
    fn report_round_trips_through_json() {
        let r = CoverageReport::sample();
        let s = serde_json::to_string(&r).unwrap();
        let back: CoverageReport = serde_json::from_str(&s).unwrap();
        assert_eq!(r, back);
    }

    #[test]
    fn report_renders_markdown_table_with_per_tool_rows() {
        let r = CoverageReport::sample();
        let md = r.render_markdown();
        assert!(md.contains("## Coverage"));
        assert!(md.contains("`create_issue`"));
        assert!(md.contains("`list_issues`"));
    }

    #[test]
    fn report_renders_html_page_highlighting_uncovered_tools() {
        let r = CoverageReport::sample();
        let html = r.render_html();
        assert!(html.contains("<!DOCTYPE html>"));
        assert!(html.contains("create_issue"));
        // list_issues was un-called: row must wear the uncovered class.
        assert!(html.contains("class=\"uncovered\""));
    }
}
