//! WOR-1321: agent cassette replay fidelity, end to end.
//!
//! A cassette recorded against one tool catalog proves nothing once the
//! server's tools drift, so `--record` now pins a fingerprint of the
//! catalog (plus the sampling params) on the cassette and replay refuses
//! on mismatch with a re-record hint. Legacy cassettes without the
//! fields keep replaying so committed recordings are not broken.
//!
//! The fixture server is the native `mcptest mock`, mirroring
//! `cli_update_cassettes.rs`: rewriting the mock manifest between runs
//! is how the live tool catalog "drifts".

#![cfg(unix)]

use std::path::Path;

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

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

/// Strip provider credentials so the run cannot resolve a real provider
/// regardless of the developer's shell.
fn strip_provider_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);
    }
}

/// Write the mock manifest at a stable path. `description` is the drift
/// knob: changing it changes the server's `tools/list` output, which must
/// change the recorded fingerprint.
fn write_weather_manifest(dir: &Path, description: &str) -> std::path::PathBuf {
    let manifest = dir.join("get_weather.yaml");
    let yaml = format!(
        r#"mock_server:
  name: weather-fixture
  tools:
    - name: get_weather
      description: "{description}"
      input_schema:
        type: object
        properties:
          city: {{ type: string }}
        required: [city]
      response:
        content:
          - type: text
            text: "It is 72 F and sunny."
"#
    );
    std::fs::write(&manifest, yaml).expect("write mock manifest");
    manifest
}

/// One-agent suite against the mock weather server.
fn write_agent_suite(dir: &Path, manifest: &Path) {
    let bin = env!("CARGO_BIN_EXE_mcptest");
    let yaml = format!(
        r#"servers:
  weather:
    command: ['{bin}', 'mock', '--tools-from', '{manifest}']
agents:
  - name: weather agent
    model: claude-sonnet-4-5
    servers: [weather]
    prompt: What is the weather in Sacramento?
"#,
        manifest = manifest.display(),
    );
    std::fs::write(dir.join("mcptest.yaml"), yaml).expect("write suite");
}

/// Locate the one recorded agent cassette (skipping the dotfile resume
/// journal that lives next to it).
fn recorded_cassette(dir: &Path) -> std::path::PathBuf {
    std::fs::read_dir(dir.join("cassettes"))
        .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")
}

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

#[test]
fn recorded_cassette_replays_then_refuses_after_tool_manifest_drift() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_weather_manifest(dir.path(), "Get the weather for a city");
    write_agent_suite(dir.path(), &manifest);

    // Record against the stub provider (no key, explicit opt-in per WOR-1312).
    run_in(dir.path(), &["--record", "--allow-stub-provider"]).code(0);

    // The recording pins the run context: fingerprint plus sampling params.
    let cassette = recorded_cassette(dir.path());
    let recorded = std::fs::read_to_string(&cassette).expect("read cassette");
    for field in ["tools_fingerprint", "temperature", "max_tokens"] {
        assert!(
            recorded.contains(field),
            "recorded cassette must pin `{field}`, got:\n{recorded}"
        );
    }

    // Same tool catalog: the cassette replays cleanly, no provider needed.
    run_in(dir.path(), &[])
        .code(0)
        .stdout(predicate::str::contains("1 passed"));

    // The server's tool catalog drifts; the replay must refuse with the
    // re-record hint instead of certifying a stale conversation.
    write_weather_manifest(dir.path(), "Get the weather AND the air quality");
    run_in(dir.path(), &[]).code(1).stderr(
        predicate::str::contains("recorded against a different tool set")
            .and(predicate::str::contains("--update-cassettes"))
            .and(predicate::str::contains("weather agent")),
    );
}

#[test]
fn stale_cassette_failure_links_its_troubleshooting_entry() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_weather_manifest(dir.path(), "Get the weather for a city");
    write_agent_suite(dir.path(), &manifest);

    run_in(dir.path(), &["--record", "--allow-stub-provider"]).code(0);

    // The user prompt drifts from the recording: replay must refuse as
    // stale and point at the troubleshooting entry (WOR-1288).
    let suite = dir.path().join("mcptest.yaml");
    let yaml = std::fs::read_to_string(&suite).expect("read suite");
    std::fs::write(&suite, yaml.replace("Sacramento", "Reno")).expect("rewrite suite");
    run_in(dir.path(), &[]).code(1).stderr(
        predicate::str::contains("is stale on field `user_prompt`").and(predicate::str::contains(
            format!("see: {}", mcptest_core::error_anchors::AGENT_CASSETTE_STALE),
        )),
    );
}

#[test]
fn legacy_cassette_without_fidelity_fields_still_replays() {
    let dir = tempfile::tempdir().unwrap();
    let manifest = write_weather_manifest(dir.path(), "Get the weather for a city");
    write_agent_suite(dir.path(), &manifest);

    run_in(dir.path(), &["--record", "--allow-stub-provider"]).code(0);

    // Rewrite the recording into the pre-WOR-1321 shape: no fingerprint, no
    // sampling params. This is exactly what a committed legacy cassette is.
    let cassette = recorded_cassette(dir.path());
    let raw = std::fs::read_to_string(&cassette).expect("read cassette");
    let mut value: serde_json::Value = serde_json::from_str(&raw).expect("cassette is JSON");
    let object = value.as_object_mut().expect("cassette is a JSON object");
    for field in ["tools_fingerprint", "temperature", "max_tokens"] {
        assert!(
            object.remove(field).is_some(),
            "recording should have pinned `{field}`"
        );
    }
    std::fs::write(&cassette, serde_json::to_string_pretty(&value).unwrap())
        .expect("write legacy cassette");

    // The legacy shape replays (with a tracing warning, not a refusal), even
    // though the current run has a fingerprint to compare against.
    run_in(dir.path(), &[])
        .code(0)
        .stdout(predicate::str::contains("1 passed"));
}
