//! WOR-1397: a hermetic agent-cassette replay re-runs the agent gates.
//!
//! PR #26 made `try_replay_cassette` re-run the cross-run gates
//! (`record_cell_gates`) over the replayed envelope, so an offline replay still
//! validates `trajectory:` and `golden_path:` instead of silently dropping
//! them. The first CI test for this was environment-coupled: the example server
//! was the Node reference server, which is absent on the Rust `check` job, so
//! the run "exited during startup" on ubuntu and macos and the test was pulled.
//!
//! This test is hermetic and cross-platform. The agent's server is a
//! `cassette:` source (initialize + tools/list replayed from JSON, no
//! subprocess), and the agent cassette is built with a real tool call so the
//! gates have a plan to validate. The tool-set fingerprint is derived from the
//! same catalog the cassette advertises, so the WOR-1321 fidelity gate matches
//! offline on every OS without a provider key.

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

use assert_cmd::Command;
use predicates::prelude::*;
use serde_json::{json, Value};

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

/// Drop provider credentials so the run cannot resolve a real model and the
/// test exercises only the offline replay path, whatever the dev's shell holds.
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);
    }
}

/// The single tool the agent's server advertises. Returned as one value so the
/// cassette's tools/list and the fidelity fingerprint cannot drift apart.
fn weather_tool() -> (&'static str, &'static str, Value) {
    (
        "get_weather",
        "Get the weather for a city.",
        json!({
            "type": "object",
            "properties": { "city": { "type": "string" } },
            "required": ["city"]
        }),
    )
}

/// Write the server cassette: an initialize handshake plus the tools/list the
/// agent replay lists to compute the live fingerprint. Pure JSON, no subprocess.
fn write_server_cassette(dir: &Path, name: &str, description: &str, schema: &Value) -> PathBuf {
    let cassette = json!({
        "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": "search", "version": "0.1" }
                }}
            },
            {
                "request": { "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} },
                "response": { "jsonrpc": "2.0", "id": 2, "result": {
                    "tools": [ { "name": name, "description": description, "inputSchema": schema } ]
                }}
            }
        ]
    });
    let path = dir.join("weather-server.json");
    std::fs::write(&path, serde_json::to_string_pretty(&cassette).unwrap()).unwrap();
    path
}

/// Write the suite: a cassette-backed server plus an agent with both gates. The
/// model, prompt, and system_prompt must match the agent cassette exactly or
/// the stale-input check refuses the replay.
fn write_suite(dir: &Path, server_path: &Path) -> PathBuf {
    let cfg = dir.join("mcptest.yaml");
    std::fs::write(
        &cfg,
        format!(
            r#"servers:
  search:
    cassette: '{server}'
agents:
  - name: trajectory replays offline
    model: claude-sonnet-4-5
    servers: [search]
    system_prompt: |
      Look up the weather by calling get_weather with the city, then stop.
    prompt: What is the weather in Sacramento?
    max_turns: 3
    max_tokens: 256
    trajectory:
      mode: subsequence
      calls:
        - name: get_weather
          args:
            subset: {{ city: Sacramento }}
    golden_path:
      calls: [get_weather]
"#,
            server = server_path.display()
        ),
    )
    .unwrap();
    cfg
}

/// Write the agent cassette: a recorded trace that actually calls get_weather,
/// so the trajectory and golden-path gates have a plan to score. The
/// system_prompt carries the trailing newline the YAML block scalar yields.
fn write_agent_cassette(dir: &Path, fingerprint: &str) {
    let cassette = json!({
        "schema_version": "v1",
        "agent_name": "trajectory replays offline",
        "model": "claude-sonnet-4-5",
        "user_prompt": "What is the weather in Sacramento?",
        "system_prompt": "Look up the weather by calling get_weather with the city, then stop.\n",
        "recorded_at": "2026-06-21T00:00:00Z",
        "tools_fingerprint": fingerprint,
        "temperature": 0.0,
        "max_tokens": 256,
        "trace": {
            "tool_calls": [
                {
                    "name": "get_weather",
                    "server": "search",
                    "args": { "city": "Sacramento" },
                    "hop_index": 0,
                    "inputs_digest": "0000000000000000"
                }
            ],
            "tool_results": [
                {
                    "call_index": 0,
                    "server": "search",
                    "is_error": false,
                    "result": {
                        "content": [ { "type": "text", "text": "{\"city\":\"Sacramento\",\"temperature\":46}" } ],
                        "isError": false
                    }
                }
            ],
            "final_response": "The weather in Sacramento is currently 46F and rainy.",
            "conversation": {
                "tokens": { "prompt": 100, "completion": 20, "total": 120 },
                "duration_ms": 1,
                "message_count": 3,
                "stop_reason": "completed",
                "turns": [
                    { "role": "assistant", "content": "tool_use search::get_weather with {\"city\":\"Sacramento\"}" },
                    { "role": "tool", "content": "{\"content\":[{\"text\":\"forecast\",\"type\":\"text\"}],\"isError\":false}" },
                    { "role": "assistant", "content": "The weather in Sacramento is currently 46F and rainy." }
                ]
            }
        }
    });
    // Replay reads cassettes from `<suite_dir>/cassettes/<agent>__<model>.json`.
    let cassettes_dir = dir.join("cassettes");
    std::fs::create_dir_all(&cassettes_dir).unwrap();
    std::fs::write(
        cassettes_dir.join("trajectory_replays_offline__claude-sonnet-4-5.json"),
        serde_json::to_string_pretty(&cassette).unwrap(),
    )
    .unwrap();
}

#[test]
fn replayed_agent_cassette_reruns_trajectory_and_golden_path_gates() {
    let dir = tempfile::tempdir().unwrap();
    let (name, description, schema) = weather_tool();

    // Fingerprint over the same catalog `list_merged_tools` rebuilds from the
    // cassette's tools/list, so `check_replay_fidelity` sees a match offline.
    // Deriving it here (instead of hard-coding a hash) keeps the fixture honest
    // if the fingerprint algorithm ever changes.
    let fingerprint = mcptest_agent::fingerprint_tools(&[mcptest_core::eval::provider::ToolSpec {
        name: name.to_string(),
        description: description.to_string(),
        input_schema: schema.clone(),
    }]);

    let server_path = write_server_cassette(dir.path(), name, description, &schema);
    let cfg = write_suite(dir.path(), &server_path);
    write_agent_cassette(dir.path(), &fingerprint);

    // Replay offline: no provider key, no subprocess. The agent row plus the
    // re-run (trajectory) and (golden-path) rows make three passing checks. A
    // regression that drops the gates on replay (the bug PR #26 fixed) would
    // show "1 passed" and fail this assertion.
    let mut cmd = mcptest();
    strip_provider_keys(&mut cmd);
    cmd.current_dir(dir.path())
        .env_remove("CI")
        .args(["run", "--config", cfg.to_str().unwrap()]);
    cmd.assert().success().stdout(
        predicate::str::contains("(trajectory)")
            .and(predicate::str::contains("(golden-path)"))
            .and(predicate::str::contains("3 passed")),
    );
}
