//! Coverage observation recorder.
//!
//! The runner allocates one [`CoverageTracker`] per suite and feeds it a
//! [`Observation`] after every tool call. At the end of the run it asks
//! the tracker to fold its state into a [`CoverageReport`] against a
//! declared surface (the `tools/list` response, declared error fixtures,
//! declared transports).
//!
//! Keeping the tracker library-side means tests can drive it without a
//! live runner. The runner wiring is left to a follow-up ticket (the CLI
//! agent owns `mcptest run`).

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

use serde_json::Value;

use super::report::{build, ArgCombination, CoverageReport};

/// Live coverage state for one suite run.
///
/// Records tool calls, normalized argument shapes, error fixtures fired,
/// and transports exercised. The struct is `Send` and `Sync`-friendly
/// when wrapped behind a `Mutex`; the runner is single-threaded inside
/// one suite today so no internal lock is taken.
#[derive(Debug, Default)]
pub struct CoverageTracker {
    tool_calls: BTreeMap<String, usize>,
    tool_arg_combinations: BTreeMap<String, BTreeSet<ArgCombination>>,
    tool_error_paths: BTreeMap<String, BTreeSet<String>>,
    error_paths_seen: BTreeSet<String>,
    transports_seen: BTreeSet<String>,
}

/// One observation the runner hands to the tracker.
///
/// Every successful or expected-error call against the server produces
/// one observation. The runner records the tool name, the raw argument
/// object (the tracker normalizes to pairwise pairs), an optional error
/// fixture name when the call was injected, and the transport in use.
#[derive(Debug, Clone)]
pub struct Observation {
    /// Tool the runner called.
    pub tool: String,
    /// JSON object holding the arguments handed to the tool. The
    /// tracker walks the top-level keys to derive pairwise pairs.
    pub args: Value,
    /// Name of a `fixtures.errors[]` entry that fired, if any.
    pub error_path: Option<String>,
    /// Transport label (`stdio`, `http`, `sse`).
    pub transport: String,
}

impl CoverageTracker {
    /// Empty tracker. Same as `Default::default()` but spelled out so
    /// call sites read naturally.
    pub fn new() -> Self {
        Self::default()
    }

    /// Record one observation.
    pub fn record(&mut self, obs: &Observation) {
        *self.tool_calls.entry(obs.tool.clone()).or_default() += 1;
        let pairs = normalize_args_to_pairs(&obs.args);
        let pairwise = pairwise_combinations(&pairs);
        let bucket = self
            .tool_arg_combinations
            .entry(obs.tool.clone())
            .or_default();
        for combo in pairwise {
            bucket.insert(combo);
        }
        if let Some(err) = obs.error_path.as_deref() {
            self.tool_error_paths
                .entry(obs.tool.clone())
                .or_default()
                .insert(err.to_owned());
            self.error_paths_seen.insert(err.to_owned());
        }
        self.transports_seen.insert(obs.transport.clone());
    }

    /// Total number of distinct pairwise argument combinations the
    /// tracker has seen across every tool. Drives `arg_coverage_pct`
    /// when paired with a universe estimate.
    pub fn arg_combinations_seen_total(&self) -> usize {
        self.tool_arg_combinations
            .values()
            .map(|set| set.len())
            .sum()
    }

    /// Fold the recorded state into a [`CoverageReport`].
    ///
    /// `declared_tools`, `declared_error_paths`, and `declared_transports`
    /// come from the runner's view of the server surface. The pairwise
    /// universe size defaults to the sum of `n*(n-1)/2` per tool's
    /// argument arity; pass `None` to let the tracker estimate it.
    pub fn into_report(
        self,
        declared_tools: &[String],
        declared_error_paths: &[String],
        declared_transports: &[String],
        arg_combination_universe_size: Option<usize>,
    ) -> CoverageReport {
        let universe = arg_combination_universe_size.unwrap_or_else(|| {
            // Fallback estimate: the number of distinct pairs the tracker
            // observed, doubled so a fully-explored surface lands at
            // 50% rather than 100% (callers that care provide the real
            // universe size).
            self.arg_combinations_seen_total().saturating_mul(2).max(1)
        });
        let combinations_for_report: BTreeMap<String, Vec<ArgCombination>> = self
            .tool_arg_combinations
            .iter()
            .map(|(k, set)| (k.clone(), set.iter().cloned().collect()))
            .collect();
        build(
            declared_tools,
            declared_error_paths,
            declared_transports,
            &self.tool_calls,
            &combinations_for_report,
            &self.tool_error_paths,
            &self.error_paths_seen,
            &self.transports_seen,
            universe,
            self.arg_combinations_seen_total(),
        )
    }
}

/// Normalize a JSON object's top-level fields into sorted
/// `(name, value)` pairs. Non-object inputs flatten to an empty list.
fn normalize_args_to_pairs(args: &Value) -> Vec<(String, String)> {
    let Some(obj) = args.as_object() else {
        return Vec::new();
    };
    let mut out: Vec<(String, String)> = obj
        .iter()
        .map(|(k, v)| (k.clone(), normalize_value(v)))
        .collect();
    out.sort();
    out
}

/// Reduce a JSON value to a short canonical string. Booleans and small
/// numbers round-trip verbatim; strings and arrays/objects collapse to
/// kind markers so we do not blow up the universe with unique payloads.
fn normalize_value(v: &Value) -> String {
    match v {
        Value::Null => "null".to_owned(),
        Value::Bool(true) => "true".to_owned(),
        Value::Bool(false) => "false".to_owned(),
        Value::Number(n) => n.to_string(),
        Value::String(s) => {
            if s.is_empty() {
                "<empty>".to_owned()
            } else if s.len() <= 20 {
                s.clone()
            } else {
                format!("<string:{}>", s.len())
            }
        }
        Value::Array(_) => "<array>".to_owned(),
        Value::Object(_) => "<object>".to_owned(),
    }
}

/// Build pairwise (2-way) combinations from a sorted list of args.
///
/// An arg list with fewer than two entries collapses into one
/// "self" combination so single-arg tools still register coverage.
fn pairwise_combinations(pairs: &[(String, String)]) -> Vec<ArgCombination> {
    if pairs.is_empty() {
        return Vec::new();
    }
    if pairs.len() == 1 {
        return vec![ArgCombination::from_pairs(pairs.to_vec())];
    }
    let mut out = Vec::with_capacity(pairs.len() * (pairs.len() - 1) / 2);
    for i in 0..pairs.len() {
        for j in (i + 1)..pairs.len() {
            out.push(ArgCombination::from_pairs(vec![
                pairs[i].clone(),
                pairs[j].clone(),
            ]));
        }
    }
    out
}

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

    fn obs(tool: &str, args: Value) -> Observation {
        Observation {
            tool: tool.to_owned(),
            args,
            error_path: None,
            transport: "stdio".to_owned(),
        }
    }

    #[test]
    fn tracker_records_one_call_per_observation() {
        let mut t = CoverageTracker::new();
        t.record(&obs("a", json!({})));
        t.record(&obs("a", json!({})));
        t.record(&obs("b", json!({})));
        assert_eq!(t.tool_calls.get("a").copied(), Some(2));
        assert_eq!(t.tool_calls.get("b").copied(), Some(1));
    }

    #[test]
    fn tracker_collects_pairwise_arg_combinations() {
        let mut t = CoverageTracker::new();
        t.record(&obs("a", json!({"x": true, "y": false, "z": 1})));
        // 3 args -> 3 pairwise combinations.
        let combos = t.tool_arg_combinations.get("a").unwrap();
        assert_eq!(combos.len(), 3);
    }

    #[test]
    fn tracker_dedups_identical_arg_combinations() {
        let mut t = CoverageTracker::new();
        t.record(&obs("a", json!({"x": true})));
        t.record(&obs("a", json!({"x": true})));
        let combos = t.tool_arg_combinations.get("a").unwrap();
        assert_eq!(combos.len(), 1);
    }

    #[test]
    fn tracker_records_error_path_against_tool() {
        let mut t = CoverageTracker::new();
        t.record(&Observation {
            tool: "a".into(),
            args: json!({}),
            error_path: Some("bad_title".into()),
            transport: "stdio".into(),
        });
        assert!(t.error_paths_seen.contains("bad_title"));
        assert!(t.tool_error_paths.get("a").unwrap().contains("bad_title"));
    }

    #[test]
    fn tracker_records_transport_seen() {
        let mut t = CoverageTracker::new();
        t.record(&obs("a", json!({})));
        t.record(&Observation {
            tool: "a".into(),
            args: json!({}),
            error_path: None,
            transport: "http".into(),
        });
        assert!(t.transports_seen.contains("stdio"));
        assert!(t.transports_seen.contains("http"));
    }

    #[test]
    fn into_report_uses_declared_tool_order() {
        let mut t = CoverageTracker::new();
        t.record(&obs("a", json!({})));
        let declared = vec!["c".to_owned(), "a".to_owned(), "b".to_owned()];
        let r = t.into_report(&declared, &[], &[], None);
        assert_eq!(r.tools[0].name, "c");
        assert_eq!(r.tools[1].name, "a");
        assert!(r.tools[1].called);
        assert!(!r.tools[0].called);
    }

    #[test]
    fn into_report_reports_zero_arg_coverage_when_nothing_seen() {
        let t = CoverageTracker::new();
        let declared = vec!["a".to_owned()];
        let r = t.into_report(&declared, &[], &[], Some(4));
        assert_eq!(r.arg_coverage_pct, 0.0);
    }

    #[test]
    fn normalize_value_collapses_long_strings_to_kind_marker() {
        let s = normalize_value(&json!("this is more than twenty characters here"));
        assert!(s.starts_with("<string:"));
    }

    #[test]
    fn pairwise_singleton_returns_one_combination() {
        let pairs = vec![("a".into(), "1".into())];
        let combos = pairwise_combinations(&pairs);
        assert_eq!(combos.len(), 1);
        assert_eq!(combos[0].pairs.len(), 1);
    }

    #[test]
    fn snapshot_assertions_register_as_calls() {
        // Documented decision: snapshot tests count toward coverage,
        // every assertion against a tool counts (see module docs).
        let mut t = CoverageTracker::new();
        t.record(&obs("snapshot_target", json!({"q": "search"})));
        let r = t.into_report(&["snapshot_target".to_owned()], &[], &[], Some(1));
        assert!(r.tools[0].called);
        assert_eq!(r.tools[0].call_count, 1);
    }
}
