//! `mcptest compliance`.
//!
//! Scores an MCP server against the compliance rubric and renders the
//! result via the format the user asked for. `--baseline` (and its
//! `--expected-failures` alias) layers the four
//! [`mcptest_core::compliance::BaselineDecision`]
//! outcomes on top of the run so CI can stay green while a known set of
//! rules is still pending.
//!
//! Two input paths feed the rubric: `--results-from <PATH>` reads a
//! pre-built JSON list of [`mcptest_core::compliance::CheckResult`]
//! records (no server involved), and `--from-suite <PATH>` runs a YAML
//! suite, including its `compliance:` test blocks, against the live
//! servers it declares (WOR-1347) and scores the outcomes.

use std::collections::BTreeSet;
use std::fs;
use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};

use anyhow::{anyhow, Context, Result};
use mcptest_core::compliance::renderers::{
    render_html, render_json, render_markdown, render_pretty, RenderInputs,
};
use mcptest_core::compliance::{
    aggregate, decide, score_run, update_baseline, AggregateOutcome, Baseline, BaselineDecision,
    CheckResult, Rubric, RuleRegistry,
};

use super::compliance_suite::run_suite_live;

/// Output format selected by `--format`.
///
/// Mirrors the four renderers in
/// [`mcptest_core::compliance::renderers`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComplianceOutputFormat {
    /// ANSI-colored text suitable for a terminal.
    Pretty,
    /// Pretty-printed JSON of the score envelope.
    Json,
    /// GitHub-flavored Markdown.
    Markdown,
    /// Self-contained HTML document.
    Html,
}

/// Plain-data input for [`run_compliance`].
///
/// Every flag the CLI parses lands here so the entry point is easy to
/// drive from tests without going through clap.
#[derive(Debug, Clone)]
pub struct ComplianceRunCliArgs {
    /// Rendered output format.
    pub format: ComplianceOutputFormat,
    /// Path to a JSON list of [`CheckResult`] records. Mutually
    /// exclusive with [`Self::from_suite`].
    pub results_from: Option<PathBuf>,
    /// Live path: run a YAML suite (its tool/resource/prompt tests and
    /// its `compliance:` test blocks) against the declared servers and
    /// synthesize [`CheckResult`] records from the outcomes. Each test
    /// name's leading registry-id token (or the whole name) becomes the
    /// rule_id. Mutually exclusive with [`Self::results_from`].
    pub from_suite: Option<PathBuf>,
    /// Server label printed in the report header.
    pub server_label: String,
    /// Override for the rule registry path. `None` means the in-tree
    /// `compliance/registry.yml` discovered relative to the working
    /// directory.
    pub registry: Option<PathBuf>,
    /// Comma-separated capability list the server declared.
    pub capabilities: Option<String>,
    /// Baseline file path. Set by `--baseline` or its
    /// `--expected-failures` alias.
    pub baseline: Option<PathBuf>,
    /// When true, rewrite the baseline from the current run.
    pub update_baseline: bool,
    /// Skip the confirmation prompt for `--update-baseline`.
    pub yes: bool,
    /// Write the current run's scorecard (a serialized `ComplianceScore`) here
    /// so a future run's `grade_delta.against:` can compare against it
    ///. `None` skips the save.
    pub save_scorecard: Option<PathBuf>,
    /// When true, also run the official `@modelcontextprotocol/conformance`
    /// suite and fold its results into the score (WOR-1307).
    pub official: bool,
    /// Override for the official suite's executable. Replaces the default
    /// `npx --yes @modelcontextprotocol/conformance@latest` argv so tests
    /// and pinned local installs avoid the network.
    pub official_cmd: Option<String>,
    /// Server URL the official suite probes. Comes from the global
    /// `--server-url`; required when [`Self::official`] is set.
    pub server_url: Option<String>,
}

/// Entry point for `mcptest compliance run`.
///
/// Loads the result list, scores it via the rubric, optionally applies
/// baseline logic, renders to the chosen format, and returns the
/// desired process exit code.
///
/// Exit codes:
/// - `0` when the run passes or the only failures are
///   [`BaselineDecision::ExpectedFailure`]
/// - `1` on a [`BaselineDecision::NewRegression`] or
///   [`BaselineDecision::StaleBaseline`], on any failed MUST when no
///   baseline is supplied, or on IO errors.
pub fn run_compliance(args: ComplianceRunCliArgs) -> Result<i32> {
    // WOR-1282: a focused suite must never gate CI green, so refuse it
    // here, before any server connect or registry load.
    if let Some(suite_path) = args.from_suite.as_deref() {
        if let Some(code) = refuse_focused_suite_in_ci(suite_path)? {
            return Ok(code);
        }
    }
    let mut results = match (&args.results_from, &args.from_suite) {
        (Some(p), None) => read_results(p)?,
        (None, Some(p)) => run_suite_live(p)?,
        (Some(_), Some(_)) => {
            anyhow::bail!(
                "mcptest compliance run: pass either --results-from or --from-suite, not both"
            );
        }
        (None, None) => {
            anyhow::bail!(
                "mcptest compliance run: requires --results-from <PATH> or --from-suite <PATH>"
            );
        }
    };
    // WOR-1307: fold the official conformance suite's results in before
    // scoring so the rubric, baseline, and every renderer see one uniform
    // CheckResult list. Without --official this block is never entered and
    // behavior is unchanged.
    if args.official {
        results.extend(run_official_suite(&args)?);
    }
    let registry = load_registry(args.registry.as_deref())?;
    let caps = parse_capabilities(args.capabilities.as_deref());
    let rubric = Rubric::new(&registry, caps);
    let score = score_run(&results, &rubric);

    let baseline = load_baseline(args.baseline.as_deref())?;
    let outcome = baseline.as_ref().map(|b| apply_baseline(&results, b));

    let rendered = render(args.format, &score, &results, &args.server_label);
    let mut stdout = io::stdout().lock();
    stdout.write_all(rendered.as_bytes())?;
    if let (Some(out), Some(b)) = (outcome.as_ref(), baseline.as_ref()) {
        write_baseline_summary(&mut stdout, &results, b, out)?;
    }

    // Save the scorecard for a future grade_delta comparison.
    if let Some(path) = args.save_scorecard.as_deref() {
        save_scorecard(path, &score)?;
    }

    if args.update_baseline {
        return handle_update_baseline(&args, &results);
    }

    // Run the compliance `grade_delta:` block, if the suite declared
    // one, and fold its PASS/FAIL into the exit code. Only the --from-suite
    // path can carry a grade_delta (the --results-from path has no suite).
    let grade_delta_failed = run_grade_delta(&args, &score, &mut stdout)?;

    // Run the compliance `spec_version_check:` block, if the suite
    // declared one, and fold its PASS/FAIL into the exit code. Like grade_delta
    // this only applies on the --from-suite path.
    let spec_check_failed = run_spec_version_check(&args, &mut stdout)?;

    let base_exit = exit_code(&score, outcome.as_ref());
    Ok(if grade_delta_failed || spec_check_failed {
        1
    } else {
        base_exit
    })
}

/// Write the current scorecard (a serialized `ComplianceScore`) to `path` so a
/// future run's `grade_delta.against:` can compare against it.
fn save_scorecard(path: &Path, score: &mcptest_core::compliance::ComplianceScore) -> Result<()> {
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            fs::create_dir_all(parent).with_context(|| format!("mkdir -p {}", parent.display()))?;
        }
    }
    let json = serde_json::to_string_pretty(score).context("serialize scorecard")?;
    fs::write(path, json).with_context(|| format!("write scorecard {}", path.display()))?;
    Ok(())
}

/// Evaluate the compliance `grade_delta:` block when the suite declared one
///. Returns `true` when the gate failed so the caller can flip the
/// exit code. Prints the PASS/FAIL line to `out`. A scorecard-load failure is
/// a suite-load error and bubbles up.
fn run_grade_delta<W: Write>(
    args: &ComplianceRunCliArgs,
    score: &mcptest_core::compliance::ComplianceScore,
    out: &mut W,
) -> Result<bool> {
    let Some(suite_path) = args.from_suite.as_deref() else {
        return Ok(false);
    };
    let Some(delta) = load_grade_delta(suite_path)? else {
        return Ok(false);
    };
    let base_dir = suite_path.parent().unwrap_or_else(|| Path::new("."));
    let outcome = mcptest_core::checks::grade_delta::evaluate(&delta, score, base_dir)
        .context("grade_delta failed to load the prior scorecard")?;
    let verdict = if outcome.passed { "PASS" } else { "FAIL" };
    let note = outcome
        .failure
        .as_deref()
        .map(|m| format!(": {m}"))
        .unwrap_or_default();
    writeln!(out, "\ngrade_delta: {verdict} {}{note}", outcome.name)?;
    Ok(!outcome.passed)
}

/// Read the suite YAML and return its parsed compliance `grade_delta:` block,
/// if any.
fn load_grade_delta(
    suite_path: &Path,
) -> Result<Option<mcptest_core::checks::grade_delta::GradeDelta>> {
    let raw = fs::read_to_string(suite_path)
        .with_context(|| format!("could not read {}", suite_path.display()))?;
    let yaml: serde_yaml::Value = serde_yaml::from_str(&raw)
        .with_context(|| format!("could not parse YAML at {}", suite_path.display()))?;
    let suite =
        mcptest_core::suite::parse(&yaml).map_err(|e| anyhow!("typed suite parse failed: {e}"))?;
    Ok(suite.grade_delta)
}

/// Evaluate the compliance `spec_version_check:` block when the suite declared
/// one. Returns `true` when the gate failed so the caller can flip
/// the exit code. Prints the PASS/FAIL line to `out`.
///
/// The block diffs the suite's pinned `spec_version` (the `from`) against the
/// block's `against:` target (the `to`) using the shipped per-version corpora
/// under the registry's directory. The no-target bootstrap (no `against:`) and
/// a missing target corpus pass with a note so adding the gate never breaks the
/// build on the day it lands.
fn run_spec_version_check<W: Write>(args: &ComplianceRunCliArgs, out: &mut W) -> Result<bool> {
    use mcptest_core::checks::spec_version_check as svc;

    let Some(suite_path) = args.from_suite.as_deref() else {
        return Ok(false);
    };
    let (pin, check) = load_spec_version_check(suite_path)?;
    let Some(check) = check else {
        return Ok(false);
    };
    let Some(target) = check.against.clone() else {
        writeln!(
            out,
            "\nspec_version_check: PASS (no target: declare `against:` a spec revision to diff into)"
        )?;
        return Ok(false);
    };
    let Some(pin) = pin else {
        anyhow::bail!(
            "spec_version_check requires `compliance.spec_version:` to be set (the revision to diff from)"
        );
    };

    let corpus_root = corpus_root_dir(args.registry.as_deref());
    let from_dir = corpus_root.join(&pin);
    let to_dir = corpus_root.join(&target);
    if !to_dir.exists() {
        writeln!(
            out,
            "\nspec_version_check: PASS (no corpus for target `{target}` under {}; nothing to diff)",
            corpus_root.display()
        )?;
        return Ok(false);
    }
    let from = mcptest_core::compliance::VersionCorpus::load_dir(&pin, &from_dir)
        .map_err(|e| anyhow!("load corpus for `{pin}` at {}: {e}", from_dir.display()))?;
    let to = mcptest_core::compliance::VersionCorpus::load_dir(&target, &to_dir)
        .map_err(|e| anyhow!("load corpus for `{target}` at {}: {e}", to_dir.display()))?;
    let diff = mcptest_core::compliance::diff_versions(&from, &to);
    let outcome = svc::evaluate(&check, &diff);
    let verdict = if outcome.passed { "PASS" } else { "FAIL" };
    let note = outcome
        .failure
        .as_deref()
        .map(|m| format!(": {m}"))
        .unwrap_or_default();
    writeln!(
        out,
        "\nspec_version_check: {verdict} {}{note}",
        outcome.name
    )?;
    Ok(!outcome.passed)
}

/// Read the suite YAML and return its pinned `spec_version` plus its parsed
/// compliance `spec_version_check:` block, if any.
fn load_spec_version_check(
    suite_path: &Path,
) -> Result<(
    Option<String>,
    Option<mcptest_core::checks::spec_version_check::SpecVersionCheck>,
)> {
    let raw = fs::read_to_string(suite_path)
        .with_context(|| format!("could not read {}", suite_path.display()))?;
    let yaml: serde_yaml::Value = serde_yaml::from_str(&raw)
        .with_context(|| format!("could not parse YAML at {}", suite_path.display()))?;
    let pin = mcptest_config::spec_version::parse_spec_version_pin(&yaml)
        .map_err(|e| anyhow!("spec_version pin: {e}"))?
        .map(|v| v.to_string());
    let suite =
        mcptest_core::suite::parse(&yaml).map_err(|e| anyhow!("typed suite parse failed: {e}"))?;
    Ok((pin, suite.spec_version_check))
}

/// Resolve the directory holding the per-version corpus subdirectories
/// (`v2024-11-05/`, `draft/`, ...). It is the registry file's parent, so a
/// custom `--registry` keeps its sibling corpus dirs; the default registry
/// lives at `compliance/registry.yml`, so the corpus root is `compliance/`.
fn corpus_root_dir(registry: Option<&Path>) -> PathBuf {
    let registry_path = registry
        .map(Path::to_path_buf)
        .unwrap_or_else(|| PathBuf::from("compliance/registry.yml"));
    registry_path
        .parent()
        .map(Path::to_path_buf)
        .unwrap_or_else(|| PathBuf::from("compliance"))
}

fn read_results(path: &Path) -> Result<Vec<CheckResult>> {
    let body = fs::read_to_string(path)
        .with_context(|| format!("read results-from {}", path.display()))?;
    let parsed: Vec<CheckResult> = serde_json::from_str(&body)
        .with_context(|| format!("parse results-from {}", path.display()))?;
    Ok(parsed)
}

/// WOR-1282: a focused suite must never gate CI green, so `only: true`
/// on any compliance check, or on any ride-along tool/resource/prompt
/// test (WOR-1348), is a config error under CI=true (exit 2, the
/// invalid-config code). Mirrors
/// `cli::handlers::run_select::refuse_focused_suite_in_ci` for
/// `mcptest run`. Returns `Ok(Some(2))` when the run must refuse.
fn refuse_focused_suite_in_ci(suite_path: &Path) -> Result<Option<i32>> {
    if !crate::cli::handlers::run::is_ci_env() {
        return Ok(None);
    }
    let raw = fs::read_to_string(suite_path)
        .with_context(|| format!("could not read {}", suite_path.display()))?;
    let yaml: serde_yaml::Value = serde_yaml::from_str(&raw)
        .with_context(|| format!("could not parse YAML at {}", suite_path.display()))?;
    let suite =
        mcptest_core::suite::parse(&yaml).map_err(|e| anyhow!("typed suite parse failed: {e}"))?;
    if suite.compliance_tests.iter().any(|c| c.only) || suite.tests.iter().any(|t| t.only) {
        eprintln!(
            "mcptest compliance run: this suite contains `only: true` and CI=true is set. \
             Focused tests must not gate CI; remove the `only:` marker(s) before merging."
        );
        return Ok(Some(2));
    }
    Ok(None)
}

/// WOR-1307: run the official `@modelcontextprotocol/conformance` suite
/// through [`OfficialBridge`] and map its scenarios into the uniform
/// [`CheckResult`] shape so they score and render exactly like native
/// checks. A missing `npx` (or a missing `--official-cmd` binary) gets a
/// clean install hint instead of a raw spawn error.
fn run_official_suite(args: &ComplianceRunCliArgs) -> Result<Vec<CheckResult>> {
    use mcptest_core::compliance::official::{
        to_check_results, OfficialBridge, OfficialBridgeError,
    };

    let Some(server_url) = args.server_url.as_deref() else {
        anyhow::bail!(
            "--official needs the global --server-url <URL> so the conformance suite \
             knows which server to probe"
        );
    };
    let mut bridge = OfficialBridge::new();
    if let Some(cmd) = &args.official_cmd {
        bridge = bridge.with_command(vec![cmd.clone()]);
    }
    let binary = args.official_cmd.as_deref().unwrap_or("npx");
    let report = bridge.run(server_url).map_err(|e| match e {
        OfficialBridgeError::NotInstalled => anyhow!(
            "official conformance suite: `{binary}` was not found on PATH. Install Node.js \
             (which provides npx, see https://nodejs.org) or point --official-cmd at a \
             local @modelcontextprotocol/conformance install"
        ),
        other => anyhow!("official conformance suite failed: {other}"),
    })?;
    eprintln!(
        "mcptest compliance: folded {} official conformance scenario(s) \
         (@modelcontextprotocol/conformance {})",
        report.scenarios.len(),
        report.tool_version
    );
    Ok(to_check_results(&report))
}

fn load_registry(path: Option<&Path>) -> Result<RuleRegistry> {
    let path = match path {
        Some(p) => p.to_path_buf(),
        None => PathBuf::from("compliance/registry.yml"),
    };
    RuleRegistry::load(&path)
        .map_err(|e| anyhow!("load compliance registry {}: {e}", path.display()))
}

fn load_baseline(path: Option<&Path>) -> Result<Option<Baseline>> {
    match path {
        Some(p) => {
            let b = Baseline::load(p).map_err(|e| anyhow!("load baseline {}: {e}", p.display()))?;
            Ok(Some(b))
        }
        None => Ok(None),
    }
}

fn parse_capabilities(raw: Option<&str>) -> BTreeSet<String> {
    let Some(raw) = raw else {
        return BTreeSet::new();
    };
    raw.split(',')
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(|s| s.to_string())
        .collect()
}

fn apply_baseline(results: &[CheckResult], baseline: &Baseline) -> AggregateOutcome {
    let decisions: Vec<BaselineDecision> = results
        .iter()
        .map(|r| decide(&r.rule_id, r.passed, baseline))
        .collect();
    aggregate(&decisions)
}

fn render(
    format: ComplianceOutputFormat,
    score: &mcptest_core::compliance::ComplianceScore,
    results: &[CheckResult],
    server_label: &str,
) -> String {
    let inputs = RenderInputs {
        score,
        results,
        server_label,
    };
    match format {
        ComplianceOutputFormat::Pretty => render_pretty(&inputs),
        ComplianceOutputFormat::Json => render_json(&inputs),
        ComplianceOutputFormat::Markdown => render_markdown(&inputs),
        ComplianceOutputFormat::Html => render_html(&inputs),
    }
}

fn write_baseline_summary<W: Write>(
    out: &mut W,
    results: &[CheckResult],
    baseline: &Baseline,
    outcome: &AggregateOutcome,
) -> io::Result<()> {
    writeln!(out, "\nBaseline summary:")?;
    writeln!(out, "  Expected failures: {}", outcome.expected_failures)?;
    writeln!(out, "  New regressions: {}", outcome.new_regressions)?;
    writeln!(out, "  Stale entries: {}", outcome.stale_entries.len())?;
    // Surface each per-rule decision with the reason and tracking URL so
    // the operator sees why CI flipped (or stayed green).
    for r in results {
        let d = decide(&r.rule_id, r.passed, baseline);
        match d {
            BaselineDecision::NormalPass => {}
            BaselineDecision::ExpectedFailure(entry) => {
                let reason = entry.reason.as_deref().unwrap_or("(no reason)");
                let tracking = entry.tracking.as_deref().unwrap_or("(no tracking URL)");
                writeln!(
                    out,
                    "  expected: {} ({reason}) tracking={tracking}",
                    entry.rule_id
                )?;
            }
            BaselineDecision::NewRegression => {
                writeln!(out, "  regression: {}", r.rule_id)?;
            }
            BaselineDecision::StaleBaseline(entry) => {
                writeln!(out, "  stale: {} (server now passes)", entry.rule_id)?;
            }
        }
    }
    Ok(())
}

fn exit_code(
    score: &mcptest_core::compliance::ComplianceScore,
    outcome: Option<&AggregateOutcome>,
) -> i32 {
    if let Some(o) = outcome {
        // Baseline owns the exit decision: only flips on regression or
        // stale. A failed MUST that the baseline lists stays green.
        return if o.should_fail { 1 } else { 0 };
    }
    // No baseline: a failed MUST is a hard fail.
    if !score.failed_must_checks.is_empty() {
        return 1;
    }
    0
}

fn handle_update_baseline(args: &ComplianceRunCliArgs, results: &[CheckResult]) -> Result<i32> {
    let Some(path) = args.baseline.clone() else {
        return Err(anyhow!(
            "--update-baseline requires --baseline (or --expected-failures) to point at a file"
        ));
    };
    if !args.yes && !prompt_confirm(&path)? {
        eprintln!("mcptest compliance: update-baseline aborted by user");
        return Ok(1);
    }
    let pairs: Vec<(String, bool)> = results
        .iter()
        .map(|r| (r.rule_id.clone(), r.passed))
        .collect();
    let fresh = update_baseline(pairs);
    let yaml = serialize_baseline(&fresh);
    if let Some(parent) = path.parent() {
        if !parent.as_os_str().is_empty() {
            fs::create_dir_all(parent).with_context(|| format!("mkdir -p {}", parent.display()))?;
        }
    }
    fs::write(&path, yaml).with_context(|| format!("write baseline {}", path.display()))?;
    eprintln!(
        "mcptest compliance: wrote {} entries to {}",
        fresh.len(),
        path.display()
    );
    Ok(0)
}

fn prompt_confirm(path: &Path) -> Result<bool> {
    eprint!(
        "Overwrite baseline at {}? Type 'yes' to confirm: ",
        path.display()
    );
    io::stderr().flush().ok();
    let mut line = String::new();
    let stdin = io::stdin();
    let mut handle = stdin.lock();
    handle.read_line(&mut line)?;
    Ok(line.trim().eq_ignore_ascii_case("yes"))
}

/// Serialize a [`Baseline`] back to the short-form YAML shape. We hand
/// roll this because [`Baseline`]'s public API only exposes the loader
/// half of the round trip.
fn serialize_baseline(baseline: &Baseline) -> String {
    let mut out = String::from("server:\n");
    for entry in baseline.iter() {
        out.push_str("  - ");
        out.push_str(&entry.rule_id);
        out.push('\n');
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcptest_core::compliance::CheckSource;
    use tempfile::tempdir;

    fn write_registry(path: &Path) {
        let yaml = r#"
version: 1
rules:
  - id: PROTO-001
    name: Initialize handshake
    category: PROTO
    severity: MUST
    applies_to: [stdio]
    spec_versions: [v2024-11-05]
    description: server answers initialize
    references: []
    section: lifecycle
    applicability: "true"
    spec_citation: spec
  - id: PROTO-010
    name: Optional client info
    category: PROTO
    severity: MAY
    applies_to: [stdio]
    spec_versions: [v2024-11-05]
    description: server echoes client info
    references: []
    section: lifecycle
    applicability: "true"
    spec_citation: spec
"#;
        fs::write(path, yaml).unwrap();
    }

    fn write_results(path: &Path, results: &[(&str, bool)]) {
        let v: Vec<CheckResult> = results
            .iter()
            .map(|(id, passed)| CheckResult {
                rule_id: (*id).into(),
                passed: *passed,
                source: CheckSource::Local,
                message: None,
                duration_ms: 1,
            })
            .collect();
        fs::write(path, serde_json::to_vec_pretty(&v).unwrap()).unwrap();
    }

    fn base_args(
        results: PathBuf,
        registry: PathBuf,
        format: ComplianceOutputFormat,
    ) -> ComplianceRunCliArgs {
        ComplianceRunCliArgs {
            format,
            results_from: Some(results),
            from_suite: None,
            server_label: "stdio://x".into(),
            registry: Some(registry),
            capabilities: None,
            baseline: None,
            update_baseline: false,
            yes: false,
            save_scorecard: None,
            official: false,
            official_cmd: None,
            server_url: None,
        }
    }

    #[test]
    fn run_compliance_returns_zero_on_a_plus_run() {
        let dir = tempdir().unwrap();
        let registry = dir.path().join("registry.yml");
        let results = dir.path().join("results.json");
        write_registry(&registry);
        write_results(&results, &[("PROTO-001", true), ("PROTO-010", true)]);
        let code =
            run_compliance(base_args(results, registry, ComplianceOutputFormat::Json)).unwrap();
        assert_eq!(code, 0);
    }

    #[test]
    fn run_compliance_returns_one_on_failed_must_without_baseline() {
        let dir = tempdir().unwrap();
        let registry = dir.path().join("registry.yml");
        let results = dir.path().join("results.json");
        write_registry(&registry);
        write_results(&results, &[("PROTO-001", false)]);
        let code =
            run_compliance(base_args(results, registry, ComplianceOutputFormat::Json)).unwrap();
        assert_eq!(code, 1);
    }

    #[test]
    fn baseline_normal_pass_decision_exits_zero() {
        // No failures, no baseline entries: NormalPass for every rule.
        let dir = tempdir().unwrap();
        let registry = dir.path().join("registry.yml");
        let results = dir.path().join("results.json");
        let baseline = dir.path().join("baseline.yml");
        write_registry(&registry);
        write_results(&results, &[("PROTO-001", true), ("PROTO-010", true)]);
        fs::write(&baseline, "server: []\n").unwrap();
        let mut args = base_args(results, registry, ComplianceOutputFormat::Pretty);
        args.baseline = Some(baseline);
        assert_eq!(run_compliance(args).unwrap(), 0);
    }

    #[test]
    fn baseline_expected_failure_decision_exits_zero() {
        // A failed MUST that the baseline lists is an ExpectedFailure;
        // CI stays green.
        let dir = tempdir().unwrap();
        let registry = dir.path().join("registry.yml");
        let results = dir.path().join("results.json");
        let baseline = dir.path().join("baseline.yml");
        write_registry(&registry);
        write_results(&results, &[("PROTO-001", false)]);
        fs::write(&baseline, "server:\n  - PROTO-001\n").unwrap();
        let mut args = base_args(results, registry, ComplianceOutputFormat::Pretty);
        args.baseline = Some(baseline);
        assert_eq!(run_compliance(args).unwrap(), 0);
    }

    #[test]
    fn baseline_new_regression_decision_exits_one() {
        // A failed MUST that the baseline does NOT list is a
        // NewRegression; exit 1.
        let dir = tempdir().unwrap();
        let registry = dir.path().join("registry.yml");
        let results = dir.path().join("results.json");
        let baseline = dir.path().join("baseline.yml");
        write_registry(&registry);
        write_results(&results, &[("PROTO-001", false)]);
        fs::write(&baseline, "server: []\n").unwrap();
        let mut args = base_args(results, registry, ComplianceOutputFormat::Pretty);
        args.baseline = Some(baseline);
        assert_eq!(run_compliance(args).unwrap(), 1);
    }

    #[test]
    fn baseline_stale_decision_exits_one() {
        // A rule that the baseline lists but the server now passes is
        // StaleBaseline; exit 1 so the baseline cannot rot in place.
        let dir = tempdir().unwrap();
        let registry = dir.path().join("registry.yml");
        let results = dir.path().join("results.json");
        let baseline = dir.path().join("baseline.yml");
        write_registry(&registry);
        write_results(&results, &[("PROTO-001", true)]);
        fs::write(&baseline, "server:\n  - PROTO-001\n").unwrap();
        let mut args = base_args(results, registry, ComplianceOutputFormat::Pretty);
        args.baseline = Some(baseline);
        assert_eq!(run_compliance(args).unwrap(), 1);
    }

    #[test]
    fn update_baseline_rewrites_file_when_yes_is_set() {
        let dir = tempdir().unwrap();
        let registry = dir.path().join("registry.yml");
        let results = dir.path().join("results.json");
        let baseline = dir.path().join("baseline.yml");
        write_registry(&registry);
        write_results(&results, &[("PROTO-001", false), ("PROTO-010", false)]);
        fs::write(&baseline, "server: []\n").unwrap();
        let mut args = base_args(results, registry, ComplianceOutputFormat::Pretty);
        args.baseline = Some(baseline.clone());
        args.update_baseline = true;
        args.yes = true;
        assert_eq!(run_compliance(args).unwrap(), 0);
        let written = fs::read_to_string(&baseline).unwrap();
        assert!(written.contains("PROTO-001"));
        assert!(written.contains("PROTO-010"));
    }

    #[test]
    fn update_baseline_without_baseline_path_errors() {
        let dir = tempdir().unwrap();
        let registry = dir.path().join("registry.yml");
        let results = dir.path().join("results.json");
        write_registry(&registry);
        write_results(&results, &[("PROTO-001", false)]);
        let mut args = base_args(results, registry, ComplianceOutputFormat::Pretty);
        args.update_baseline = true;
        args.yes = true;
        let err = run_compliance(args).unwrap_err();
        assert!(err.to_string().contains("--update-baseline requires"));
    }

    #[test]
    fn parse_capabilities_handles_comma_list() {
        let caps = parse_capabilities(Some("tools, resources , prompts"));
        assert!(caps.contains("tools"));
        assert!(caps.contains("resources"));
        assert!(caps.contains("prompts"));
        assert_eq!(caps.len(), 3);
    }

    #[test]
    fn parse_capabilities_handles_none_and_empty() {
        assert!(parse_capabilities(None).is_empty());
        assert!(parse_capabilities(Some("")).is_empty());
        assert!(parse_capabilities(Some("  ,  ")).is_empty());
    }

    // `grade_delta` save + load round trip.

    #[test]
    fn save_scorecard_writes_a_reloadable_scorecard() {
        let dir = tempdir().unwrap();
        let registry = dir.path().join("registry.yml");
        let results = dir.path().join("results.json");
        let scorecard = dir.path().join("scorecard.json");
        write_registry(&registry);
        write_results(&results, &[("PROTO-001", true), ("PROTO-010", true)]);
        let mut args = base_args(results, registry, ComplianceOutputFormat::Json);
        args.save_scorecard = Some(scorecard.clone());
        assert_eq!(run_compliance(args).unwrap(), 0);
        // The saved file deserializes back into a ComplianceScore, which is
        // exactly what grade_delta's `against:` loads.
        let raw = fs::read_to_string(&scorecard).unwrap();
        let _: mcptest_core::compliance::ComplianceScore =
            serde_json::from_str(&raw).expect("saved scorecard round-trips");
    }

    #[test]
    fn load_grade_delta_reads_the_compliance_object_form() {
        let dir = tempdir().unwrap();
        let suite = dir.path().join("suite.yml");
        fs::write(
            &suite,
            "servers:\n  local: { command: [\"echo\", \"hi\"] }\ncompliance:\n  grade_delta:\n    against: ./prior.json\n  tests:\n    - name: PROTO-001\n      server: local\n      check: initialize\n",
        )
        .unwrap();
        let delta = load_grade_delta(&suite)
            .unwrap()
            .expect("grade_delta present");
        assert_eq!(
            delta.against.as_deref(),
            Some(std::path::Path::new("./prior.json"))
        );
    }

    #[test]
    fn load_grade_delta_is_none_without_the_block() {
        let dir = tempdir().unwrap();
        let suite = dir.path().join("suite.yml");
        fs::write(
            &suite,
            "servers:\n  local: { command: [\"echo\", \"hi\"] }\ncompliance:\n  tests:\n    - name: PROTO-001\n      server: local\n      check: initialize\n",
        )
        .unwrap();
        assert!(load_grade_delta(&suite).unwrap().is_none());
    }

    // `spec_version_check` load + corpus-dir resolution.

    #[test]
    fn load_spec_version_check_reads_pin_and_block() {
        let dir = tempdir().unwrap();
        let suite = dir.path().join("suite.yml");
        fs::write(
            &suite,
            "servers:\n  local: { command: [\"echo\", \"hi\"] }\ncompliance:\n  spec_version: v2024-11-05\n  spec_version_check:\n    against: v2025-06-18\n  tests:\n    - name: PROTO-001\n      server: local\n      check: initialize\n",
        )
        .unwrap();
        let (pin, check) = load_spec_version_check(&suite).unwrap();
        assert_eq!(pin.as_deref(), Some("v2024-11-05"));
        let check = check.expect("spec_version_check present");
        assert_eq!(check.against.as_deref(), Some("v2025-06-18"));
    }

    #[test]
    fn load_spec_version_check_is_none_without_the_block() {
        let dir = tempdir().unwrap();
        let suite = dir.path().join("suite.yml");
        fs::write(
            &suite,
            "servers:\n  local: { command: [\"echo\", \"hi\"] }\ncompliance:\n  spec_version: v2024-11-05\n  tests:\n    - name: PROTO-001\n      server: local\n      check: initialize\n",
        )
        .unwrap();
        let (pin, check) = load_spec_version_check(&suite).unwrap();
        assert_eq!(pin.as_deref(), Some("v2024-11-05"));
        assert!(check.is_none());
    }

    #[test]
    fn corpus_root_dir_is_the_registry_parent() {
        let custom = PathBuf::from("/etc/mcptest/compliance/registry.yml");
        assert_eq!(
            corpus_root_dir(Some(&custom)),
            PathBuf::from("/etc/mcptest/compliance")
        );
        // The default registry path lives under `compliance/`, so the corpus
        // root defaults to `compliance`.
        assert_eq!(corpus_root_dir(None), PathBuf::from("compliance"));
    }
}
