//! Integration tests for `mcptest run --update-cassettes` (WOR-1265).
//!
//! The flag is the cassette twin of `--update-snapshots`: it re-records every
//! in-scope server cassette from the live target named in the cassette's
//! `meta` block (and routes agent cassettes through the `--record` path),
//! overwriting the files in place. These tests drive the compiled binary
//! against `mcptest mock` so the whole path runs end to end: the CI refusal
//! gate, the re-record-on-drift flow with a minimal diff, the `--filter`
//! scoping, and the actionable error for a cassette with no live target.

use assert_cmd::Command;
use predicates::prelude::*;

fn mcptest() -> Command {
    Command::cargo_bin("mcptest").expect("binary builds")
}

#[test]
fn refuses_to_update_cassettes_when_ci_env_is_set() {
    mcptest()
        .env("CI", "true")
        .args(["run", "--update-cassettes"])
        .assert()
        .code(5)
        .stderr(
            predicate::str::contains("--update-cassettes refused")
                .and(predicate::str::contains("--allow-update-in-ci")),
        );
}

#[test]
fn allow_update_in_ci_bypasses_the_refusal() {
    let dir = tempfile::tempdir().unwrap();
    mcptest()
        .current_dir(dir.path())
        .env("CI", "true")
        .args(["run", "--update-cassettes", "--allow-update-in-ci"])
        .assert()
        .success()
        .stdout(predicate::str::contains("no config at"));
}

#[test]
fn ci_env_set_to_other_value_does_not_block_update() {
    // The guard matches only the literal "true", same as --update-snapshots.
    let dir = tempfile::tempdir().unwrap();
    mcptest()
        .current_dir(dir.path())
        .env("CI", "local-dev")
        .args(["run", "--update-cassettes"])
        .assert()
        .success()
        .stdout(predicate::str::contains("no config at"));
}

/// Write a `mcptest mock` manifest for an `echo` tool returning `text`, at a
/// stable path so re-calling with new text changes the live server's output
/// between recordings.
#[cfg(unix)]
fn write_echo_manifest(dir: &std::path::Path, text: &str) -> std::path::PathBuf {
    let manifest = format!(
        r#"mock_server:
  name: echo
  tools:
    - name: echo
      description: "Echo"
      input_schema:
        type: object
      response:
        content:
          - type: text
            text: "{text}"
"#
    );
    let path = dir.join("echo.yaml");
    std::fs::write(&path, manifest).expect("write mock manifest");
    path
}

/// Seed cassette for the echo server. Carries the live target in `meta` via
/// `source_command`, which is what `--update-cassettes` re-records from.
#[cfg(unix)]
fn write_seed_cassette(
    dir: &std::path::Path,
    manifest: &std::path::Path,
    text: &str,
) -> std::path::PathBuf {
    let bin = env!("CARGO_BIN_EXE_mcptest");
    let cassette = format!(
        r#"{{
  "version": "1",
  "meta": {{ "source_command": "{bin} mock --tools-from {manifest}" }},
  "exchanges": [
    {{
      "request": {{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {{}} }},
      "response": {{ "jsonrpc": "2.0", "id": 1, "result": {{ "protocolVersion": "2025-03-26", "capabilities": {{}}, "serverInfo": {{ "name": "echo", "version": "0.1" }} }} }}
    }},
    {{
      "request": {{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {{ "name": "echo", "arguments": {{ "msg": "hi" }} }} }},
      "response": {{ "jsonrpc": "2.0", "id": 2, "result": {{ "content": [ {{ "type": "text", "text": "{text}" }} ] }} }}
    }}
  ]
}}"#,
        manifest = manifest.display(),
    );
    let path = dir.join("cassette.json");
    std::fs::write(&path, cassette).expect("write seed cassette");
    path
}

/// Suite with one cassette-backed server and one tool test expecting `text`.
#[cfg(unix)]
fn write_suite(dir: &std::path::Path, text: &str) -> std::path::PathBuf {
    let yaml = format!(
        r#"servers:
  recorded:
    cassette: './cassette.json'
tools:
  - name: echo baseline
    server: recorded
    tool: echo
    args: {{ msg: hi }}
    expect:
      - target: result.content[0].text
        matcher: {{ exact: "{text}" }}
"#
    );
    let path = dir.join("mcptest.yaml");
    std::fs::write(&path, yaml).expect("write suite");
    path
}

#[cfg(unix)]
fn run_in(dir: &std::path::Path, extra: &[&str]) -> assert_cmd::assert::Assert {
    let mut cmd = mcptest();
    cmd.current_dir(dir).env_remove("CI").arg("run");
    cmd.args(extra);
    cmd.assert()
}

#[cfg(unix)]
#[test]
fn update_cassettes_re_records_a_drifted_server_cassette_minimally() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_echo_manifest(dir.path(), "first");
    let cassette = write_seed_cassette(dir.path(), &manifest, "first");
    write_suite(dir.path(), "first");

    // The seed cassette replays and passes with no live server involved.
    run_in(dir.path(), &[])
        .code(0)
        .stdout(predicate::str::contains("1 passed"));

    // First --update-cassettes run canonicalizes the hand-authored seed into
    // a real recording (live initialize params, normalized responses), so the
    // later drift diff is recording-vs-recording, not hand-author-vs-recording.
    run_in(dir.path(), &["--update-cassettes"]).code(0).stdout(
        predicate::str::contains("Re-recorded 1 server cassette(s)")
            .and(predicate::str::contains("1 passed")),
    );
    let baseline = std::fs::read_to_string(&cassette).expect("baseline cassette");

    // The server legitimately changes: new mock response, suite updated to
    // expect it. The stale cassette still replays "first", so the run fails.
    write_echo_manifest(dir.path(), "second");
    write_suite(dir.path(), "second");
    run_in(dir.path(), &[])
        .code(1)
        .stdout(predicate::str::contains("1 failed"));

    // --update-cassettes refreshes the recording from the live target and the
    // same run goes green against it.
    run_in(dir.path(), &["--update-cassettes"]).code(0).stdout(
        predicate::str::contains("Re-recorded 1 server cassette(s) (1 changed)")
            .and(predicate::str::contains("1 passed")),
    );

    // The refresh is minimal: the normalized recordings differ only on the
    // changed response text, line for line.
    let refreshed = std::fs::read_to_string(&cassette).expect("refreshed cassette");
    let before: Vec<&str> = baseline.lines().collect();
    let after: Vec<&str> = refreshed.lines().collect();
    assert_eq!(
        before.len(),
        after.len(),
        "refresh should not add or drop lines:\n--- before\n{baseline}\n--- after\n{refreshed}"
    );
    let diffs: Vec<(&str, &str)> = before
        .iter()
        .zip(after.iter())
        .filter(|(b, a)| b != a)
        .map(|(b, a)| (*b, *a))
        .collect();
    assert_eq!(
        diffs.len(),
        1,
        "only the changed response should differ, got {diffs:#?}"
    );
    assert!(
        diffs[0].0.contains("first") && diffs[0].1.contains("second"),
        "the one differing line should be the response text, got {diffs:?}"
    );

    // And a plain replay run stays green against the refreshed cassette.
    run_in(dir.path(), &[])
        .code(0)
        .stdout(predicate::str::contains("1 passed"));
}

/// Seed cassette with no `meta` source keys, for the suite-declared
/// refresh-target path (WOR-1279).
#[cfg(unix)]
fn write_metaless_cassette(dir: &std::path::Path, text: &str) -> std::path::PathBuf {
    let cassette = format!(
        r#"{{
  "version": "1",
  "meta": {{}},
  "exchanges": [
    {{
      "request": {{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {{}} }},
      "response": {{ "jsonrpc": "2.0", "id": 1, "result": {{ "protocolVersion": "2025-03-26", "capabilities": {{}}, "serverInfo": {{ "name": "echo", "version": "0.1" }} }} }}
    }},
    {{
      "request": {{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": {{ "name": "echo", "arguments": {{ "msg": "hi" }} }} }},
      "response": {{ "jsonrpc": "2.0", "id": 2, "result": {{ "content": [ {{ "type": "text", "text": "{text}" }} ] }} }}
    }}
  ]
}}"#
    );
    let path = dir.join("cassette.json");
    std::fs::write(&path, cassette).expect("write metaless cassette");
    path
}

#[cfg(unix)]
#[test]
fn suite_declared_target_refreshes_a_cassette_without_meta() {
    // WOR-1279: `command:` next to `cassette:` is the refresh target, so a
    // cassette with no `meta.source_command` still re-records.
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_echo_manifest(dir.path(), "fresh");
    write_metaless_cassette(dir.path(), "stale");
    let bin = env!("CARGO_BIN_EXE_mcptest");
    let yaml = format!(
        r#"servers:
  recorded:
    cassette: './cassette.json'
    command: ['{bin}', 'mock', '--tools-from', '{manifest}']
tools:
  - name: echo baseline
    server: recorded
    tool: echo
    args: {{ msg: hi }}
    expect:
      - target: result.content[0].text
        matcher: {{ exact: "fresh" }}
"#,
        manifest = manifest.display(),
    );
    std::fs::write(dir.path().join("mcptest.yaml"), yaml).expect("write suite");

    run_in(dir.path(), &["--update-cassettes"]).code(0).stdout(
        predicate::str::contains("Re-recorded 1 server cassette(s) (1 changed)")
            .and(predicate::str::contains("1 passed")),
    );
    // The refreshed recording replays green without the live target.
    run_in(dir.path(), &[])
        .code(0)
        .stdout(predicate::str::contains("1 passed"));
}

/// Suite with two cassette servers: `refreshable` carries a live target in
/// its cassette meta, `static` does not. Used by the `--filter` scoping and
/// the no-live-target error tests.
#[cfg(unix)]
fn write_two_server_suite(dir: &std::path::Path) -> std::path::PathBuf {
    let manifest = write_echo_manifest(dir, "first");
    write_seed_cassette(dir, &manifest, "first");
    // The static cassette is valid for replay but names no live target.
    let static_cassette = r#"{
  "version": "1",
  "exchanges": [
    {
      "request": { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} },
      "response": { "jsonrpc": "2.0", "id": 1, "result": { "protocolVersion": "2025-03-26", "capabilities": {}, "serverInfo": { "name": "static", "version": "0.1" } } }
    },
    {
      "request": { "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "echo", "arguments": { "msg": "hi" } } },
      "response": { "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "static" } ] } }
    }
  ]
}"#;
    std::fs::write(dir.join("static.json"), static_cassette).expect("write static cassette");
    let yaml = r#"servers:
  refreshable:
    cassette: './cassette.json'
  static:
    cassette: './static.json'
tools:
  - name: alpha replays refreshable
    server: refreshable
    tool: echo
    args: { msg: hi }
    expect:
      - target: result.content[0].text
        matcher: { exact: "first" }
  - name: beta replays static
    server: static
    tool: echo
    args: { msg: hi }
    expect:
      - target: result.content[0].text
        matcher: { exact: "static" }
"#;
    let path = dir.join("mcptest.yaml");
    std::fs::write(&path, yaml).expect("write suite");
    path
}

#[cfg(unix)]
#[test]
fn filter_limits_which_cassettes_are_touched() {
    let dir = tempfile::tempdir().unwrap();
    write_two_server_suite(dir.path());
    let static_before =
        std::fs::read_to_string(dir.path().join("static.json")).expect("static cassette");

    // Only `alpha` is selected, so only the refreshable cassette is in scope;
    // the static cassette (which has no live target) is never touched and the
    // run does not error on it.
    run_in(dir.path(), &["--update-cassettes", "--filter", "alpha"])
        .code(0)
        .stdout(
            predicate::str::contains("Re-recorded 1 server cassette(s)")
                .and(predicate::str::contains("1 passed")),
        );

    let static_after =
        std::fs::read_to_string(dir.path().join("static.json")).expect("static cassette");
    assert_eq!(
        static_before, static_after,
        "a filtered-out cassette must not be rewritten"
    );
}

#[cfg(unix)]
#[test]
fn cassette_without_live_target_fails_with_actionable_error() {
    let dir = tempfile::tempdir().unwrap();
    write_two_server_suite(dir.path());

    // With no filter, the static cassette is in scope, and it cannot be
    // re-recorded: the error names the meta keys to add.
    run_in(dir.path(), &["--update-cassettes"]).code(1).stderr(
        predicate::str::contains("no live target to re-record from is declared")
            .and(predicate::str::contains(
                "`command:` or `url:` next to `cassette:`",
            ))
            .and(predicate::str::contains("source_command"))
            .and(predicate::str::contains("source_url")),
    );
}

/// Agent half of the flag: `--update-cassettes` routes agent cells through
/// the `--record` path, so a stale agent cassette is overwritten from the
/// live dispatch (the deterministic stub model here, since no provider key
/// is set) instead of failing the stale-cassette check.
#[cfg(unix)]
#[test]
fn update_cassettes_overwrites_a_stale_agent_cassette() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_echo_manifest(dir.path(), "hello");
    let bin = env!("CARGO_BIN_EXE_mcptest");
    let yaml = format!(
        r#"servers:
  echo:
    command: ['{bin}', 'mock', '--tools-from', '{manifest}']
agents:
  - name: echo agent
    model: claude-sonnet-4-5
    servers: [echo]
    prompt: Say hello please
"#,
        manifest = manifest.display(),
    );
    std::fs::write(dir.path().join("mcptest.yaml"), yaml).expect("write suite");

    let strip_keys = |cmd: &mut Command| {
        for var in [
            "ANTHROPIC_API_KEY",
            "OPENAI_API_KEY",
            "GEMINI_API_KEY",
            "GOOGLE_API_KEY",
            "MISTRAL_API_KEY",
        ] {
            cmd.env_remove(var);
        }
    };

    // Record the baseline agent cassette against the stub provider. With no
    // key the stub must be requested explicitly (WOR-1312).
    let mut record = mcptest();
    strip_keys(&mut record);
    record
        .current_dir(dir.path())
        .env_remove("CI")
        .args(["run", "--record", "--allow-stub-provider"])
        .assert()
        .code(0);
    let cassette_dir = dir.path().join("cassettes");
    // The record-mode resume journal (`.run-journal.json`) lives next to the
    // cassettes; skip dotfiles so we pick the actual agent recording.
    let cassette = std::fs::read_dir(&cassette_dir)
        .expect("cassettes dir exists after --record")
        .filter_map(|e| e.ok())
        .map(|e| e.path())
        .find(|p| {
            p.extension().is_some_and(|ext| ext == "json")
                && p.file_name()
                    .and_then(|n| n.to_str())
                    .is_some_and(|n| !n.starts_with('.'))
        })
        .expect("agent cassette recorded");

    // Make the cassette stale: the recorded prompt no longer matches the YAML.
    let recorded = std::fs::read_to_string(&cassette).expect("read agent cassette");
    assert!(recorded.contains("Say hello please"));
    let stale = recorded.replace("Say hello please", "A prompt that drifted");
    std::fs::write(&cassette, stale).expect("write stale cassette");

    // --update-cassettes takes the record path: the stale file is overwritten
    // from a live dispatch rather than failing the staleness check.
    let mut update = mcptest();
    strip_keys(&mut update);
    update
        .current_dir(dir.path())
        .env_remove("CI")
        .args(["run", "--update-cassettes", "--allow-stub-provider"])
        .assert()
        .code(0);
    let refreshed = std::fs::read_to_string(&cassette).expect("read refreshed cassette");
    assert!(
        refreshed.contains("Say hello please"),
        "the agent cassette should have been re-recorded from the YAML prompt"
    );
}
