//! `mcptest model-compat`.
//!
//! Three subcommands compose the v1.1 headline feature: `capture` writes
//! a baseline snapshot, `diff` compares two baselines, and `run` ties the
//! two together by re-running the suite against a candidate model and
//! diffing the result against a saved baseline. Exit codes follow the
//! ADR 0026 rubric: 0 PASS, 6 DRIFT, 1 FAIL.
//!
//! The library work landed in batch 22 (`mcptest_core::model_compat`).
//! This module is the thin CLI surface that wires arguments into those
//! library entry points and renders results.

use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use mcptest_core::model_compat::{diff_baselines, BaselineDiff, BaselineFile, Severity};

/// Output format for `model-compat diff`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffOutputFormat {
    /// Color-tinted plain text suitable for terminals.
    Pretty,
    /// Pretty-printed JSON of the underlying [`BaselineDiff`].
    Json,
}

/// Plain-data inputs for the `capture` subcommand.
///
/// The runner integration lands separately; for now `capture` accepts a
/// pre-built [`BaselineFile`] supplied via `--input` so tests can exercise
/// the CLI without a real MCP server. The `--model` and `--output` flags
/// are mandatory in the user-facing args; this struct also carries an
/// optional filter glob for parity with the library entry point.
#[derive(Debug, Clone)]
pub struct CaptureRunArgs {
    /// Provider-qualified model identifier (for example
    /// `anthropic:claude-sonnet-4.5`). Stored verbatim in the baseline.
    pub model: String,
    /// Path to write the baseline JSON to. Overwritten if it exists.
    pub output: PathBuf,
    /// Optional `--filter` glob narrowing the assertion list. `None` means
    /// keep every captured assertion.
    pub filter: Option<String>,
    /// Pre-built baseline file to re-stamp, when provided. Takes precedence
    /// over `config`; `None` means capture live from `config`.
    pub input: Option<PathBuf>,
    /// Suite to capture live (WOR-1385). Used when `input` is `None`.
    pub config: Option<PathBuf>,
}

/// Plain-data inputs for the `diff` subcommand.
#[derive(Debug, Clone)]
pub struct DiffRunArgs {
    /// Path to the baseline (left-hand side of the diff).
    pub baseline: PathBuf,
    /// Path to the candidate (right-hand side of the diff).
    pub candidate: PathBuf,
    /// Pretty vs JSON output.
    pub format: DiffOutputFormat,
    /// Optional `--filter` glob narrowing which assertion ids show up.
    pub filter: Option<String>,
}

/// Plain-data inputs for the `run` subcommand.
///
/// The live runner integration is owned by a follow-up ticket; until that
/// lands, `run` accepts a pre-captured `--candidate` so we can wire
/// PASS/DRIFT/FAIL exit handling end to end.
#[derive(Debug, Clone)]
pub struct RunArgs {
    /// Path to the baseline (left-hand side).
    pub baseline: PathBuf,
    /// Provider-qualified model identifier. Stored in the run report so
    /// the user can see which model produced the candidate.
    pub model: String,
    /// Path to the candidate baseline. Replaces a live runner for v1.1.
    pub candidate: PathBuf,
}

/// Library entry point for `mcptest model-compat capture`.
///
/// Reads the supplied input baseline, optionally filters it, swaps the
/// model_id for the user-supplied value, and writes the canonical JSON
/// to `--output`. Returns the desired process exit code: 0 on success
/// and 1 on any IO or parse failure.
pub fn run_capture(args: CaptureRunArgs) -> Result<i32> {
    let Some(input_path) = &args.input else {
        // No pre-built baseline: capture live from the suite (WOR-1385).
        let Some(config) = &args.config else {
            eprintln!(
                "mcptest model-compat capture: provide --input <baseline> or --config <suite> (live capture)"
            );
            return Ok(crate::cli::exit_codes::EXIT_INVALID_INVOCATION);
        };
        return crate::commands::model_compat_live::run_live_capture(
            config,
            &args.model,
            &args.output,
            args.filter.as_deref(),
            false,
        );
    };
    let input = read_baseline(input_path)?;
    let mut output = input;
    output.model_id = args.model.clone();
    if let Some(filter) = &args.filter {
        let matcher = glob_matcher(filter)?;
        output.assertions.retain(|a| matcher(&a.id));
        if output.assertions.is_empty() {
            eprintln!(
                "mcptest model-compat capture: filter '{}' matched zero assertions",
                filter
            );
            return Ok(1);
        }
    }
    write_baseline(&args.output, &output)?;
    println!(
        "mcptest model-compat capture: wrote {} assertions to {}",
        output.assertions.len(),
        args.output.display()
    );
    Ok(0)
}

/// Library entry point for `mcptest model-compat diff`.
///
/// Loads two baseline files, runs [`diff_baselines`], and renders the
/// result via [`BaselineDiff::render_pretty`] for pretty output or
/// `serde_json` for JSON. Exit code mirrors `run` so the same diff can be
/// used as a CI gate: 0 PASS, 6 DRIFT, 1 FAIL.
pub fn run_diff(args: DiffRunArgs) -> Result<i32> {
    let baseline = read_baseline(&args.baseline)?;
    let candidate = read_baseline(&args.candidate)?;
    let mut diff =
        diff_baselines(&baseline, &candidate).with_context(|| "diff_baselines failed")?;
    if let Some(filter) = &args.filter {
        let matcher = glob_matcher(filter)?;
        diff = filter_diff(diff, &matcher);
    }
    match args.format {
        DiffOutputFormat::Pretty => {
            // The CLI honors NO_COLOR by checking the env at print time;
            // tests pass `--format json` so the color path is exercised
            // by the unit tests in `mcptest-core`.
            let color = std::env::var_os("NO_COLOR").is_none();
            print!("{}", diff.render_pretty(color));
        }
        DiffOutputFormat::Json => {
            let body = serde_json::to_string_pretty(&diff)?;
            println!("{body}");
        }
    }
    Ok(exit_code_for(&diff))
}

/// Library entry point for `mcptest model-compat run`.
///
/// Loads the baseline plus the pre-captured candidate, diffs them, prints
/// a pretty summary, and returns the rubric exit code. The live runner
/// integration is the follow-up ticket called out in the module docs.
pub fn run_run(args: RunArgs) -> Result<i32> {
    let baseline = read_baseline(&args.baseline)?;
    let candidate = read_baseline(&args.candidate)?;
    let diff = diff_baselines(&baseline, &candidate).with_context(|| "diff_baselines failed")?;
    let color = std::env::var_os("NO_COLOR").is_none();
    println!("mcptest model-compat run: candidate model={}", args.model);
    print!("{}", diff.render_pretty(color));
    Ok(exit_code_for(&diff))
}

/// Read and parse a model-compat baseline snapshot from `path`.
///
/// Raised to `pub(crate)` so the model-compat run lane (WOR-1486) loads its
/// `baseline:` and `candidate:` snapshots through the same reader the
/// `diff`/`run` subcommands use, keeping one parse-and-error path.
pub(crate) fn read_baseline(path: &Path) -> Result<BaselineFile> {
    let body =
        fs::read_to_string(path).with_context(|| format!("read baseline {}", path.display()))?;
    let parsed: BaselineFile = serde_json::from_str(&body)
        .with_context(|| format!("parse baseline {}", path.display()))?;
    Ok(parsed)
}

fn write_baseline(path: &Path, bf: &BaselineFile) -> 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 body = serde_json::to_vec_pretty(bf)
        .with_context(|| format!("serialize baseline {}", path.display()))?;
    fs::write(path, body).with_context(|| format!("write baseline {}", path.display()))?;
    Ok(())
}

/// Translate a [`BaselineDiff`] into the rubric exit code.
///
/// Per ADR 0026: PASS exits 0, DRIFT exits 6 (the existing quality-gate
/// exit code reused from `--coverage-threshold`), and FAIL exits 1.
fn exit_code_for(diff: &BaselineDiff) -> i32 {
    if diff.summary.fail > 0 {
        return 1;
    }
    if diff.summary.drift > 0 {
        return 6;
    }
    let _ = Severity::Pass; // referenced via summary; keeps the import alive
    0
}

fn filter_diff(diff: BaselineDiff, matcher: &dyn Fn(&str) -> bool) -> BaselineDiff {
    let mut out = BaselineDiff {
        entries: Vec::new(),
        summary: Default::default(),
        invariant_results: diff
            .invariant_results
            .into_iter()
            .filter(|(id, _)| matcher(id))
            .collect(),
        // The fingerprint is a property of the whole baselines, not of any one
        // filtered test, so it carries through a `--filter` unchanged.
        fingerprint: diff.fingerprint,
    };
    for entry in diff.entries {
        if !matcher(&entry.test_id) {
            continue;
        }
        out.summary.total += 1;
        match entry.severity {
            Severity::Pass => out.summary.pass += 1,
            Severity::Drift => out.summary.drift += 1,
            Severity::Fail => out.summary.fail += 1,
        }
        out.entries.push(entry);
    }
    out
}

/// Compile a simple glob to a closure. The shape matches the capture
/// library's filter: `*` is "any sequence", `?` is "any single char",
/// everything else is literal. We keep a hand-rolled matcher in-CLI so
/// the wrapper does not pull `regex` into the binary just for filtering.
fn glob_matcher(pattern: &str) -> Result<Box<dyn Fn(&str) -> bool>> {
    let owned = pattern.to_owned();
    Ok(Box::new(move |s: &str| glob_match(&owned, s)))
}

fn glob_match(pattern: &str, text: &str) -> bool {
    // Iterative backtracking matcher. `*` is greedy; we remember the last
    // `*` position and the input cursor so a mismatch can rewind.
    let pat: Vec<char> = pattern.chars().collect();
    let txt: Vec<char> = text.chars().collect();
    let (mut pi, mut ti) = (0usize, 0usize);
    let (mut star_pi, mut star_ti): (Option<usize>, usize) = (None, 0);
    while ti < txt.len() {
        if pi < pat.len() {
            let c = pat[pi];
            if c == '*' {
                star_pi = Some(pi);
                star_ti = ti;
                pi += 1;
                continue;
            }
            if c == '?' || c == txt[ti] {
                pi += 1;
                ti += 1;
                continue;
            }
        }
        if let Some(sp) = star_pi {
            pi = sp + 1;
            star_ti += 1;
            ti = star_ti;
            continue;
        }
        return false;
    }
    while pi < pat.len() && pat[pi] == '*' {
        pi += 1;
    }
    pi == pat.len()
}

#[cfg(test)]
mod tests {
    use super::*;
    use mcptest_core::model_compat::{
        CapturedExchange, ConfigFingerprint, ContentBlock, FinishReason,
    };
    use tempfile::tempdir;

    fn baseline_with(ids: &[&str]) -> BaselineFile {
        BaselineFile {
            schema: "https://mcptest.sh/schema/model-compat/v1.json".into(),
            version: 1,
            model_id: "anthropic:claude-sonnet-4.5".into(),
            model_version: "20251022".into(),
            mcptest_version: "1.1.0".into(),
            server_version: "1.0.0".into(),
            server_config_hash: "sha256:0".into(),
            config_fingerprint: ConfigFingerprint::default(),
            assertions: ids
                .iter()
                .map(|id| CapturedExchange {
                    id: (*id).into(),
                    content: vec![ContentBlock::Text {
                        text: format!("body for {id}"),
                    }],
                    tool_calls: vec![],
                    finish_reason: FinishReason::Stop,
                    mcp_messages: vec![],
                })
                .collect(),
            invariants: vec![],
        }
    }

    #[test]
    fn capture_rewrites_model_and_writes_output() {
        let dir = tempdir().unwrap();
        let input = dir.path().join("input.json");
        let output = dir.path().join("out.json");
        fs::write(
            &input,
            serde_json::to_vec(&baseline_with(&["a", "b"])).unwrap(),
        )
        .unwrap();
        let args = CaptureRunArgs {
            model: "anthropic:claude-sonnet-5.0".into(),
            output: output.clone(),
            filter: None,
            input: Some(input),
            config: None,
        };
        let code = run_capture(args).unwrap();
        assert_eq!(code, 0);
        let written: BaselineFile = serde_json::from_slice(&fs::read(&output).unwrap()).unwrap();
        assert_eq!(written.model_id, "anthropic:claude-sonnet-5.0");
        assert_eq!(written.assertions.len(), 2);
    }

    #[test]
    fn capture_filter_narrows_assertions() {
        let dir = tempdir().unwrap();
        let input = dir.path().join("input.json");
        let output = dir.path().join("out.json");
        fs::write(
            &input,
            serde_json::to_vec(&baseline_with(&["a::one", "a::two", "b::one"])).unwrap(),
        )
        .unwrap();
        let args = CaptureRunArgs {
            model: "anthropic:claude-sonnet-5.0".into(),
            output: output.clone(),
            filter: Some("a::*".into()),
            input: Some(input),
            config: None,
        };
        let code = run_capture(args).unwrap();
        assert_eq!(code, 0);
        let written: BaselineFile = serde_json::from_slice(&fs::read(&output).unwrap()).unwrap();
        assert_eq!(written.assertions.len(), 2);
    }

    #[test]
    fn diff_identical_baselines_exits_zero() {
        let dir = tempdir().unwrap();
        let a = dir.path().join("a.json");
        let b = dir.path().join("b.json");
        let baseline = baseline_with(&["a", "b"]);
        fs::write(&a, serde_json::to_vec(&baseline).unwrap()).unwrap();
        fs::write(&b, serde_json::to_vec(&baseline).unwrap()).unwrap();
        let args = DiffRunArgs {
            baseline: a,
            candidate: b,
            format: DiffOutputFormat::Json,
            filter: None,
        };
        let code = run_diff(args).unwrap();
        assert_eq!(code, 0);
    }

    #[test]
    fn diff_missing_candidate_assertion_exits_fail() {
        let dir = tempdir().unwrap();
        let a = dir.path().join("a.json");
        let b = dir.path().join("b.json");
        let big = baseline_with(&["a", "b"]);
        let small = baseline_with(&["a"]);
        fs::write(&a, serde_json::to_vec(&big).unwrap()).unwrap();
        fs::write(&b, serde_json::to_vec(&small).unwrap()).unwrap();
        let args = DiffRunArgs {
            baseline: a,
            candidate: b,
            format: DiffOutputFormat::Json,
            filter: None,
        };
        let code = run_diff(args).unwrap();
        assert_eq!(code, 1, "missing assertion is a FAIL");
    }

    #[test]
    fn run_delegates_to_diff_for_exit_code() {
        let dir = tempdir().unwrap();
        let a = dir.path().join("a.json");
        let b = dir.path().join("b.json");
        let baseline = baseline_with(&["a", "b"]);
        fs::write(&a, serde_json::to_vec(&baseline).unwrap()).unwrap();
        fs::write(&b, serde_json::to_vec(&baseline).unwrap()).unwrap();
        let args = RunArgs {
            baseline: a,
            model: "anthropic:claude-sonnet-5.0".into(),
            candidate: b,
        };
        let code = run_run(args).unwrap();
        assert_eq!(code, 0);
    }

    #[test]
    fn glob_matcher_accepts_star() {
        let m = glob_matcher("a::*").unwrap();
        assert!(m("a::one"));
        assert!(m("a::two"));
        assert!(!m("b::one"));
    }
}
