//! Per-run tool coverage for `mcptest run --coverage` (WOR-1386).
//!
//! The executor records one [`Observation`](mcptest_core::coverage::Observation)
//! per dispatched tool call into a shared tracker. This module sources the
//! declared tool surface from the live pool before the run and folds the
//! tracker into a [`CoverageReport`] after, so the report reflects what the run
//! actually exercised against what the servers expose. Unlike the static
//! `mcptest coverage` subcommand (which reads what the suite declares each test
//! calls), this records the live dispatches.

use std::collections::BTreeSet;
use std::sync::{Arc, Mutex};

use serde_json::Value;

use mcptest_core::connector::ServerPool;
use mcptest_core::coverage::{CoverageReport, CoverageTracker, Thresholds};
use mcptest_core::executor::TargetTransport;
use mcptest_core::suite::ResolvedSuite;

/// Shared handle the executor records into and the run path folds after. The
/// mutex serializes records across the runner's parallel tests.
pub(super) type CoverageHandle = Arc<Mutex<CoverageTracker>>;

/// The declared surface a per-run coverage report is scored against: the tools
/// the connected servers expose (via `tools/list`), the error fixtures the
/// suite declares, and the transports in play. Sourced while the pool is live,
/// before the drive shuts it down.
pub(super) struct CoverageSurface {
    declared_tools: Vec<String>,
    declared_error_paths: Vec<String>,
    declared_transports: Vec<String>,
}

/// A live per-run coverage capture: the shared tracker the executor records
/// into, plus the declared surface (sourced before the run) the recording is
/// scored against once the run finishes.
pub(super) struct Coverage {
    tracker: CoverageHandle,
    surface: CoverageSurface,
}

impl Coverage {
    /// Clone the shared tracker handle for the executor to record into.
    pub(super) fn handle(&self) -> CoverageHandle {
        Arc::clone(&self.tracker)
    }
}

/// Prepare per-run coverage when `--coverage` is set: install a tracker and
/// source the declared surface from the live pool now, before the drive shuts
/// it down. Returns `None` when coverage is off, so the run path stays a no-op.
pub(super) async fn prepare(
    enabled: bool,
    suite: &ResolvedSuite,
    pool: &Arc<ServerPool>,
) -> Option<Coverage> {
    if !enabled {
        return None;
    }
    Some(Coverage {
        tracker: new_tracker(),
        surface: fetch_surface(suite, pool).await,
    })
}

/// Fold a finished capture into its report. Consumes the capture; the executor
/// has already recorded every dispatched call into the shared tracker.
pub(super) fn into_report(coverage: Coverage) -> CoverageReport {
    build_report(&coverage.tracker, &coverage.surface)
}

/// A fresh shared tracker for the executor to record dispatched calls into.
fn new_tracker() -> CoverageHandle {
    Arc::new(Mutex::new(CoverageTracker::new()))
}

/// Source the declared coverage surface from the live pool and the suite.
///
/// Tool names come from a `tools/list` on every connected server (best effort:
/// an unreachable server contributes nothing rather than failing the run, since
/// coverage is telemetry, not a correctness gate). Transports come from the
/// suite's server specs, error paths from its `fixtures.errors`.
pub(super) async fn fetch_surface(
    suite: &ResolvedSuite,
    pool: &Arc<ServerPool>,
) -> CoverageSurface {
    let mut tools = BTreeSet::new();
    let mut transports = BTreeSet::new();
    for (name, spec) in &suite.servers {
        transports.insert(TargetTransport::from_spec(spec).label().to_string());
        let Some(server) = pool.get(name).await else {
            continue;
        };
        let Ok(catalog) = server
            .client
            .request("tools/list", serde_json::json!({}))
            .await
        else {
            continue;
        };
        for tool in tools_array(&catalog) {
            if let Some(tool_name) = tool.get("name").and_then(Value::as_str) {
                tools.insert(tool_name.to_owned());
            }
        }
    }
    let declared_error_paths = suite
        .fixtures
        .errors
        .iter()
        .map(|e| e.name.clone())
        .collect();
    CoverageSurface {
        declared_tools: tools.into_iter().collect(),
        declared_error_paths,
        declared_transports: transports.into_iter().collect(),
    }
}

/// Pull the `tools` array out of a `tools/list` response, whether the client
/// handed back the bare result (`{tools: [...]}`) or the full envelope
/// (`{result: {tools: [...]}}`).
fn tools_array(catalog: &Value) -> &[Value] {
    catalog
        .get("tools")
        .or_else(|| catalog.get("result").and_then(|r| r.get("tools")))
        .and_then(Value::as_array)
        .map(Vec::as_slice)
        .unwrap_or(&[])
}

/// Fold the recorded tracker into a report against the declared surface. Takes
/// the tracker's state out of the mutex so the caller's `Arc` can be dropped;
/// the universe estimate is left to the tracker (no static catalog arity here).
pub(super) fn build_report(tracker: &CoverageHandle, surface: &CoverageSurface) -> CoverageReport {
    let recorded = {
        let mut guard = tracker
            .lock()
            .unwrap_or_else(|poisoned| poisoned.into_inner());
        std::mem::take(&mut *guard)
    };
    recorded.into_report(
        &surface.declared_tools,
        &surface.declared_error_paths,
        &surface.declared_transports,
        None,
    )
}

/// Apply the optional `--coverage-threshold` gate to a per-run report. Prints
/// each unmet threshold to stderr (machine stdout stays clean) and returns
/// `true` when the gate failed, so the caller can fold exit code 6.
pub(super) fn gate_threshold(
    report: &CoverageReport,
    thresholds: Option<&Thresholds>,
    quiet: bool,
) -> bool {
    let Some(thresholds) = thresholds else {
        return false;
    };
    match thresholds.evaluate(report) {
        Ok(()) => false,
        Err(unmet) => {
            if !quiet {
                for miss in &unmet {
                    eprintln!("coverage threshold not met: {miss}");
                }
            }
            true
        }
    }
}

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

    #[test]
    fn tools_array_reads_bare_and_enveloped_shapes() {
        let bare = serde_json::json!({ "tools": [{ "name": "a" }] });
        let enveloped = serde_json::json!({ "result": { "tools": [{ "name": "b" }] } });
        assert_eq!(tools_array(&bare).len(), 1);
        assert_eq!(tools_array(&enveloped)[0]["name"], "b");
        assert!(tools_array(&serde_json::json!({})).is_empty());
    }

    #[test]
    fn build_report_folds_recorded_calls_against_declared_tools() {
        use mcptest_core::coverage::Observation;
        let tracker = new_tracker();
        tracker.lock().unwrap().record(&Observation {
            tool: "search".to_string(),
            args: serde_json::json!({ "q": "hi" }),
            error_path: None,
            transport: "stdio".to_string(),
        });
        let surface = CoverageSurface {
            declared_tools: vec!["search".to_string(), "fetch".to_string()],
            declared_error_paths: vec![],
            declared_transports: vec!["stdio".to_string()],
        };
        let report = build_report(&tracker, &surface);
        // One of two declared tools was called: 50% tool coverage.
        assert_eq!(report.tool_coverage_pct, 50.0);
    }

    #[test]
    fn gate_threshold_is_noop_without_thresholds() {
        let surface = CoverageSurface {
            declared_tools: vec!["a".to_string()],
            declared_error_paths: vec![],
            declared_transports: vec![],
        };
        let report = build_report(&new_tracker(), &surface);
        assert!(!gate_threshold(&report, None, true));
    }
}
