//! Baseline capture.
//!
//! Library entry point for `mcptest model-baseline capture`. Walks
//! every test in a suite, runs it through a transport-supplied runner,
//! and writes the result as a canonical JSON file. Same inputs produce
//! a byte-identical file (ADR 0026 idempotency contract).

use std::io::Write;
use std::path::{Path, PathBuf};

use async_trait::async_trait;
use sha2::{Digest, Sha256};

use crate::model_compat::error::ModelCompatError;
use crate::model_compat::invariants::Invariant;
use crate::model_compat::model::{
    BaselineFile, CapturedExchange, ConfigFingerprint, BASELINE_SCHEMA_URL, BASELINE_SCHEMA_VERSION,
};

/// Parameters supplied by the CLI agent's future `model-baseline
/// capture` subcommand. Kept as a struct so we can extend without
/// breaking the function signature.
#[derive(Debug, Clone)]
pub struct CaptureOptions {
    /// Provider-qualified model identifier.
    pub model_id: String,
    /// Model version string. Treated as opaque metadata.
    pub model_version: String,
    /// Version of the `mcptest` binary producing the capture.
    pub mcptest_version: String,
    /// Server version reported by the MCP handshake.
    pub server_version: String,
    /// Resolved server configuration as a JSON blob; used for the
    /// content hash.
    pub server_config: serde_json::Value,
    /// Optional `--filter` glob (or simple substring) narrowing the
    /// captured test list. `None` means "capture every test".
    pub filter: Option<String>,
    /// Invariants the capture should pin into the baseline. Mirrors
    /// what the diff engine evaluates on the candidate side later.
    pub invariants: Vec<Invariant>,
    /// Active invariant and variance rule names (ADR 0026
    /// `config_fingerprint`).
    pub fingerprint: ConfigFingerprint,
}

impl Default for CaptureOptions {
    fn default() -> Self {
        Self {
            model_id: "unknown".into(),
            model_version: "unknown".into(),
            mcptest_version: crate::version::full().into(),
            server_version: "unknown".into(),
            server_config: serde_json::Value::Null,
            filter: None,
            invariants: vec![],
            fingerprint: ConfigFingerprint::default(),
        }
    }
}

/// Trait the live runner plugs into when it lands. Tests pass an
/// in-memory fake so capture can be exercised without a real MCP
/// server. The trait is async because real runners speak the
/// JSON-RPC client.
#[async_trait]
pub trait CaptureRunner: Send + Sync {
    /// Return the list of test identifiers in the suite, ordered as
    /// the runner would execute them.
    async fn list_tests(&self) -> Result<Vec<String>, ModelCompatError>;

    /// Run one test and produce a captured exchange.
    async fn run_test(&self, test_id: &str) -> Result<CapturedExchange, ModelCompatError>;
}

/// Library entry point for capture. The runner supplies the test
/// enumeration and execution; this function handles filtering,
/// canonical serialization, content hashing, and atomic write.
pub async fn capture_baseline<R: CaptureRunner>(
    runner: &R,
    options: &CaptureOptions,
    output_path: &Path,
) -> Result<BaselineFile, ModelCompatError> {
    let tests = runner.list_tests().await?;
    let selected = filter_tests(&tests, options.filter.as_deref())?;
    if selected.is_empty() {
        return Err(ModelCompatError::EmptyCapture);
    }

    let mut assertions = Vec::with_capacity(selected.len());
    for id in &selected {
        assertions.push(runner.run_test(id).await?);
    }
    assertions.sort_by(|a, b| a.id.cmp(&b.id));

    let baseline = BaselineFile {
        schema: BASELINE_SCHEMA_URL.into(),
        version: BASELINE_SCHEMA_VERSION,
        model_id: options.model_id.clone(),
        model_version: options.model_version.clone(),
        mcptest_version: options.mcptest_version.clone(),
        server_version: options.server_version.clone(),
        server_config_hash: hash_server_config(&options.server_config),
        config_fingerprint: options.fingerprint.clone(),
        assertions,
        invariants: options.invariants.clone(),
    };

    write_atomic(output_path, &canonical_json(&baseline)?)?;
    Ok(baseline)
}

/// Filter test ids by a simple glob (supports `*` wildcards) or by a
/// substring. Returns the ids in the original order so capture is
/// stable across runs.
fn filter_tests(tests: &[String], pattern: Option<&str>) -> Result<Vec<String>, ModelCompatError> {
    let Some(p) = pattern else {
        return Ok(tests.to_vec());
    };
    let matcher = compile_glob(p)?;
    Ok(tests.iter().filter(|id| matcher(id)).cloned().collect())
}

fn compile_glob(
    pattern: &str,
) -> Result<Box<dyn Fn(&str) -> bool + Send + Sync>, ModelCompatError> {
    // Lightweight glob: `*` matches any sequence, anything else is
    // literal. We avoid a new dep because the runner already
    // documents this surface in CLI help.
    let mut regex = String::with_capacity(pattern.len() + 4);
    regex.push('^');
    for ch in pattern.chars() {
        match ch {
            '*' => regex.push_str(".*"),
            '?' => regex.push('.'),
            c if c.is_alphanumeric()
                || c == '_'
                || c == ':'
                || c == '-'
                || c == '/'
                || c == '.' =>
            {
                regex.push(c)
            }
            c => {
                regex.push('\\');
                regex.push(c);
            }
        }
    }
    regex.push('$');
    let re = regex::Regex::new(&regex).map_err(|e| ModelCompatError::InvalidFilter {
        pattern: pattern.into(),
        reason: e.to_string(),
    })?;
    Ok(Box::new(move |s: &str| re.is_match(s)))
}

fn hash_server_config(v: &serde_json::Value) -> String {
    let mut hasher = Sha256::new();
    let canonical = canonical_value_string(v);
    hasher.update(canonical.as_bytes());
    format!("sha256:{:x}", hasher.finalize())
}

fn canonical_value_string(v: &serde_json::Value) -> String {
    // serde_json::Value's `Map` is a BTreeMap when the
    // `preserve_order` feature is off (our default), so iterating
    // already yields sorted keys. Produce the same compact shape as
    // `serde_json::to_string` to keep the hash byte-identical across
    // runs.
    match v {
        serde_json::Value::Null => "null".into(),
        serde_json::Value::Bool(b) => b.to_string(),
        serde_json::Value::Number(n) => n.to_string(),
        serde_json::Value::String(s) => serde_json::to_string(s).unwrap_or_default(),
        serde_json::Value::Array(items) => {
            let parts: Vec<String> = items.iter().map(canonical_value_string).collect();
            format!("[{}]", parts.join(","))
        }
        serde_json::Value::Object(map) => {
            let parts: Vec<String> = map
                .iter()
                .map(|(k, v)| {
                    let key = serde_json::to_string(k).unwrap_or_default();
                    let val = canonical_value_string(v);
                    format!("{key}:{val}")
                })
                .collect();
            format!("{{{}}}", parts.join(","))
        }
    }
}

/// Serialize a [`BaselineFile`] canonically: stable key order, no
/// trailing whitespace, no pretty indentation. Two captures with the
/// same inputs produce the same bytes.
pub(crate) fn canonical_json(bf: &BaselineFile) -> Result<Vec<u8>, ModelCompatError> {
    // serde_json on a typed struct emits fields in source order, which
    // is itself stable. We re-serialize via Value to get sorted object
    // keys (Value::Object backs onto BTreeMap by default), which gives
    // us byte-identical output regardless of which side authored the
    // struct.
    let v = serde_json::to_value(bf)?;
    let s = canonical_value_string(&v);
    Ok(s.into_bytes())
}

/// Write `bytes` to `path` atomically: temp file in the same directory,
/// fsync, rename. A Ctrl-C between fsync and rename leaves the temp
/// file behind but not a partial baseline.
pub(crate) fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), ModelCompatError> {
    let parent = path.parent().unwrap_or_else(|| Path::new("."));
    std::fs::create_dir_all(parent)?;
    let tmp = temp_sibling(path);
    {
        let mut file = std::fs::File::create(&tmp)?;
        file.write_all(bytes)?;
        file.sync_all()?;
    }
    std::fs::rename(&tmp, path)?;
    Ok(())
}

fn temp_sibling(path: &Path) -> PathBuf {
    let mut name = path
        .file_name()
        .map(|n| n.to_os_string())
        .unwrap_or_default();
    name.push(".tmp");
    let mut sibling = path.to_path_buf();
    sibling.set_file_name(name);
    sibling
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::model_compat::model::{ContentBlock, FinishReason, ToolCall};
    use std::sync::Mutex;
    use tempfile::tempdir;

    struct FakeRunner {
        ids: Vec<String>,
        responses: Mutex<std::collections::HashMap<String, CapturedExchange>>,
    }

    #[async_trait]
    impl CaptureRunner for FakeRunner {
        async fn list_tests(&self) -> Result<Vec<String>, ModelCompatError> {
            Ok(self.ids.clone())
        }
        async fn run_test(&self, id: &str) -> Result<CapturedExchange, ModelCompatError> {
            let map = self.responses.lock().expect("lock");
            map.get(id)
                .cloned()
                .ok_or_else(|| ModelCompatError::InvalidFilter {
                    pattern: id.into(),
                    reason: "unknown test".into(),
                })
        }
    }

    fn fake(ids: &[&str]) -> FakeRunner {
        let mut responses = std::collections::HashMap::new();
        for id in ids {
            responses.insert(
                (*id).into(),
                CapturedExchange {
                    id: (*id).into(),
                    content: vec![ContentBlock::Text {
                        text: format!("response for {id}"),
                    }],
                    tool_calls: vec![ToolCall {
                        name: "noop".into(),
                        arguments: serde_json::json!({"id": id}),
                        raw_arguments: None,
                    }],
                    finish_reason: FinishReason::Stop,
                    mcp_messages: vec![],
                },
            );
        }
        FakeRunner {
            ids: ids.iter().map(|s| (*s).to_string()).collect(),
            responses: Mutex::new(responses),
        }
    }

    #[tokio::test]
    async fn happy_path_captures_three_tests() {
        let runner = fake(&["a::t::1", "a::t::2", "a::t::3"]);
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("baseline.json");
        let bf = capture_baseline(&runner, &CaptureOptions::default(), &path)
            .await
            .expect("capture");
        assert_eq!(bf.assertions.len(), 3);
        assert!(path.exists());
    }

    #[tokio::test]
    async fn idempotent_capture_is_byte_identical() {
        let runner = fake(&["a::t::1", "a::t::2", "a::t::3"]);
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("baseline.json");
        capture_baseline(&runner, &CaptureOptions::default(), &path)
            .await
            .expect("capture 1");
        let first = std::fs::read(&path).expect("read 1");
        capture_baseline(&runner, &CaptureOptions::default(), &path)
            .await
            .expect("capture 2");
        let second = std::fs::read(&path).expect("read 2");
        assert_eq!(first, second);
    }

    #[tokio::test]
    async fn filter_narrows_test_list() {
        let runner = fake(&["a::t::one", "a::t::two", "b::t::one"]);
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("baseline.json");
        let opts = CaptureOptions {
            filter: Some("a::*".into()),
            ..CaptureOptions::default()
        };
        let bf = capture_baseline(&runner, &opts, &path)
            .await
            .expect("capture");
        let ids: Vec<&str> = bf.assertions.iter().map(|e| e.id.as_str()).collect();
        assert_eq!(ids, vec!["a::t::one", "a::t::two"]);
    }

    #[tokio::test]
    async fn empty_filter_match_errors() {
        let runner = fake(&["a::t::one"]);
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("baseline.json");
        let opts = CaptureOptions {
            filter: Some("nomatch::*".into()),
            ..CaptureOptions::default()
        };
        let err = capture_baseline(&runner, &opts, &path)
            .await
            .expect_err("error");
        assert!(matches!(err, ModelCompatError::EmptyCapture));
    }

    #[test]
    fn atomic_write_leaves_no_partial_on_missing_temp() {
        let dir = tempdir().expect("tempdir");
        let path = dir.path().join("baseline.json");
        // Simulate Ctrl-C between create and rename: write only the
        // temp file and confirm `path` does not exist.
        let tmp = temp_sibling(&path);
        std::fs::write(&tmp, b"partial").expect("write tmp");
        assert!(!path.exists(), "no partial baseline must land");
    }

    #[test]
    fn canonical_json_sorts_object_keys() {
        let v = serde_json::json!({"b": 1, "a": 2});
        let s = canonical_value_string(&v);
        // BTreeMap-backed Map yields keys in sorted order regardless of
        // insertion order.
        assert!(s.starts_with("{\"a\":"));
    }
}
