//! End-to-end tests for `mcptest distill` (WOR-1263): a recorded cassette
//! becomes a draft suite with one test per tool exchange, the suite
//! validates and replays green against the cassette with zero edits,
//! repeats earn exact-value assertions, and suspicious recorded values
//! get review markers plus a stderr summary.

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

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

/// Build a server-cassette JSON document from `(tool, args, text)`
/// triples, prefixed with the initialize handshake the replay transport
/// expects first.
fn cassette_json(calls: &[(&str, serde_json::Value, &str)]) -> String {
    let mut exchanges = vec![serde_json::json!({
        "request": { "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {} },
        "response": { "jsonrpc": "2.0", "id": 1, "result": {
            "protocolVersion": "2025-03-26", "capabilities": {},
            "serverInfo": { "name": "recorded", "version": "0.1" } } }
    })];
    for (i, (tool, args, text)) in calls.iter().enumerate() {
        let id = i as i64 + 2;
        exchanges.push(serde_json::json!({
            "request": { "jsonrpc": "2.0", "id": id, "method": "tools/call",
                         "params": { "name": tool, "arguments": args } },
            "response": { "jsonrpc": "2.0", "id": id, "result": {
                "isError": false,
                "content": [{ "type": "text", "text": text }] } }
        }));
    }
    serde_json::json!({ "version": "1", "exchanges": exchanges }).to_string()
}

fn write_cassette(dir: &std::path::Path, body: &str) -> std::path::PathBuf {
    let path = dir.join("cassette.json");
    std::fs::write(&path, body).expect("write cassette");
    path
}

fn parse_suite(yaml: &str) -> serde_json::Value {
    let parsed: serde_yaml::Value = serde_yaml::from_str(yaml).expect("suite parses as YAML");
    serde_json::to_value(parsed).expect("yaml converts to json")
}

#[test]
fn distills_one_test_per_tool_exchange_with_verbatim_args() {
    let dir = tempfile::tempdir().unwrap();
    let cassette = write_cassette(
        dir.path(),
        &cassette_json(&[
            ("echo", serde_json::json!({ "msg": "one" }), "one"),
            ("search", serde_json::json!({ "q": "x", "limit": 3 }), "hit"),
        ]),
    );
    let assert = mcptest()
        .args(["distill", cassette.to_str().unwrap()])
        .assert()
        .success()
        .stderr(predicate::str::contains("distilled 2 test(s)"));
    let yaml = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let doc = parse_suite(&yaml);
    let tools = doc["tools"].as_array().expect("tools list");
    assert_eq!(tools.len(), 2);
    assert_eq!(tools[0]["name"], "echo: distilled from cassette #1");
    assert_eq!(tools[0]["tool"], "echo");
    assert_eq!(tools[0]["args"], serde_json::json!({ "msg": "one" }));
    assert_eq!(tools[1]["name"], "search: distilled from cassette #2");
    assert_eq!(
        tools[1]["args"],
        serde_json::json!({ "q": "x", "limit": 3 })
    );
    // Single observations: no exact-value assertions beyond the isError
    // invariant.
    for t in tools {
        let exacts: Vec<&serde_json::Value> = t["expect"]["assertions"]
            .as_array()
            .expect("assertions list")
            .iter()
            .filter(|e| e["matcher"].get("exact").is_some() && e["target"] != "result.isError")
            .collect();
        assert!(exacts.is_empty(), "single calls must not pin exact values");
    }
}

#[test]
fn distilled_suite_validates_and_runs_green_against_the_cassette_with_zero_edits() {
    let dir = tempfile::tempdir().unwrap();
    let cassette = write_cassette(
        dir.path(),
        &cassette_json(&[("echo", serde_json::json!({ "msg": "hi" }), "hi")]),
    );
    let suite = dir.path().join("suite.yml");

    // --output writes the file; stdout stays clean.
    let assert = mcptest()
        .current_dir(dir.path())
        .args([
            "distill",
            cassette.to_str().unwrap(),
            "--output",
            suite.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stderr(predicate::str::contains("wrote"));
    assert!(suite.exists(), "--output must write the suite file");
    let stdout = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    assert!(stdout.is_empty(), "suite must go to the file, not stdout");

    mcptest()
        .current_dir(dir.path())
        .args(["validate", "--config", suite.to_str().unwrap()])
        .assert()
        .success();

    mcptest()
        .current_dir(dir.path())
        .env_remove("CI")
        .args(["run", "--config", suite.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("1 passed"));
}

#[test]
fn repeated_identical_exchanges_distill_to_exact_assertions_and_replay_green() {
    let dir = tempfile::tempdir().unwrap();
    let cassette = write_cassette(
        dir.path(),
        &cassette_json(&[
            ("echo", serde_json::json!({ "msg": "hi" }), "hi"),
            ("echo", serde_json::json!({ "msg": "hi" }), "hi"),
        ]),
    );
    let suite = dir.path().join("suite.yml");
    mcptest()
        .current_dir(dir.path())
        .args([
            "distill",
            cassette.to_str().unwrap(),
            "--output",
            suite.to_str().unwrap(),
        ])
        .assert()
        .success();

    // The repeats act as the stability probe: the stable text leaf is
    // pinned exactly.
    let yaml = std::fs::read_to_string(&suite).unwrap();
    let doc = parse_suite(&yaml);
    let tools = doc["tools"].as_array().expect("tools list");
    assert_eq!(tools.len(), 2, "one test per recorded exchange");
    for t in tools {
        let has_exact_text = t["expect"]["assertions"]
            .as_array()
            .expect("assertions")
            .iter()
            .any(|e| e["target"] == "result.content[0].text" && e["matcher"]["exact"] == "hi");
        assert!(has_exact_text, "repeat must pin the stable leaf: {yaml}");
    }

    // And the suite replays green against its own cassette, zero edits.
    mcptest()
        .current_dir(dir.path())
        .env_remove("CI")
        .args(["run", "--config", suite.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("2 passed"));
}

#[test]
fn review_markers_flag_emails_and_bearer_like_args_with_a_stderr_summary() {
    let dir = tempfile::tempdir().unwrap();
    let cassette = write_cassette(
        dir.path(),
        &cassette_json(&[
            ("echo", serde_json::json!({ "msg": "plain" }), "plain"),
            (
                "invite",
                serde_json::json!({ "address": "ada.lovelace@acme-corp.io" }),
                "sent",
            ),
            // The key is deliberately not secret-named: secret-named keys
            // are scrubbed by the redactor before review runs.
            (
                "fetch",
                serde_json::json!({ "cursor": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" }),
                "ok",
            ),
        ]),
    );
    let assert = mcptest()
        .args(["distill", cassette.to_str().unwrap()])
        .assert()
        .success()
        .stderr(
            predicate::str::contains("review: 2 value(s) look like personal or live data")
                .and(predicate::str::contains("email: 1"))
                .and(predicate::str::contains("token: 1")),
        );
    let yaml = String::from_utf8(assert.get_output().stdout.clone()).unwrap();
    let lines: Vec<&str> = yaml.lines().collect();
    let preceding = |name: &str| -> &str {
        let needle = format!("- name: \"{name}\"");
        let idx = lines
            .iter()
            .position(|l| l.contains(&needle))
            .unwrap_or_else(|| panic!("test `{name}` missing from:\n{yaml}"));
        lines[idx - 1]
    };
    let marker = "# review: possible personal or live data";
    assert!(
        !preceding("echo: distilled from cassette #1").contains("# review:"),
        "clean test must carry no review marker"
    );
    assert_eq!(
        preceding("invite: distilled from cassette #2").trim(),
        marker
    );
    assert_eq!(
        preceding("fetch: distilled from cassette #3").trim(),
        marker
    );
    // Flag-only: the recorded values are not rewritten.
    assert!(yaml.contains("ada.lovelace@acme-corp.io"));
    assert!(yaml.contains("a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0"));
}

#[test]
fn agent_cassette_distills_to_a_retarget_template() {
    let dir = tempfile::tempdir().unwrap();
    let agent = serde_json::json!({
        "schema_version": "v1",
        "agent_name": "weather query",
        "model": "claude-sonnet-4-5",
        "user_prompt": "What is the weather in Sacramento?",
        "recorded_at": "2026-01-01T00:00:00Z",
        "trace": {
            "tool_calls": [
                { "name": "get_weather", "server": "weather",
                  "args": { "city": "Sacramento" } }
            ],
            "tool_results": [
                { "call_index": 0, "is_error": false,
                  "result": { "content": [{ "type": "text", "text": "72 F, sunny" }] } }
            ],
            "final_response": "It is 72 F and sunny.",
            "conversation": {
                "tokens": { "prompt": 80, "completion": 25, "total": 105 },
                "duration_ms": 1234, "message_count": 4, "turns": []
            }
        }
    });
    let cassette = dir.path().join("weather__claude.json");
    std::fs::write(&cassette, agent.to_string()).unwrap();
    let suite = dir.path().join("suite.yml");
    mcptest()
        .args([
            "distill",
            cassette.to_str().unwrap(),
            "--output",
            suite.to_str().unwrap(),
        ])
        .assert()
        .success()
        .stderr(predicate::str::contains("retarget"));
    let yaml = std::fs::read_to_string(&suite).unwrap();
    assert!(yaml.contains("TODO(retarget)"), "{yaml}");
    let doc = parse_suite(&yaml);
    assert_eq!(
        doc["tools"][0]["name"],
        "get_weather: distilled from weather__claude #1"
    );
    assert_eq!(doc["tools"][0]["server"], "weather");
    assert_eq!(
        doc["tools"][0]["args"],
        serde_json::json!({ "city": "Sacramento" })
    );
    // The template suite still validates as written.
    mcptest()
        .args(["validate", "--config", suite.to_str().unwrap()])
        .assert()
        .success();
}

#[test]
fn cassette_without_tool_calls_fails_with_a_clear_error() {
    let dir = tempfile::tempdir().unwrap();
    let cassette = write_cassette(dir.path(), &cassette_json(&[]));
    mcptest()
        .args(["distill", cassette.to_str().unwrap()])
        .assert()
        .failure()
        .stderr(predicate::str::contains("no completed tools/call"));
}

/// End-to-end record-then-distill loop: seed a cassette pointing at a
/// `mcptest mock` live target, canonicalize it through `--update-cassettes`
/// (a real recording pass), then distill the recording and replay it green.
#[cfg(unix)]
#[test]
fn recorded_cassette_distills_and_replays_green_end_to_end() {
    let dir = tempfile::tempdir().unwrap();
    let bin = env!("CARGO_BIN_EXE_mcptest");
    let manifest = dir.path().join("echo.yaml");
    std::fs::write(
        &manifest,
        r#"mock_server:
  name: echo
  tools:
    - name: echo
      description: "Echo"
      input_schema:
        type: object
      response:
        content:
          - type: text
            text: "hello from the live server"
"#,
    )
    .unwrap();
    let cassette = dir.path().join("cassette.json");
    std::fs::write(
        &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": "hello from the live server" }} ] }} }} }}
  ]
}}"#,
            manifest = manifest.display(),
        ),
    )
    .unwrap();
    std::fs::write(
        dir.path().join("mcptest.yaml"),
        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: "hello from the live server" }
"#,
    )
    .unwrap();

    // Re-record the cassette from the live mock so the distilled input is
    // a genuine recording, not a hand-authored fixture.
    mcptest()
        .current_dir(dir.path())
        .env_remove("CI")
        .args(["run", "--update-cassettes"])
        .assert()
        .success()
        .stdout(predicate::str::contains("Re-recorded 1 server cassette(s)"));

    let suite = dir.path().join("distilled.yml");
    mcptest()
        .current_dir(dir.path())
        .args([
            "distill",
            cassette.to_str().unwrap(),
            "--output",
            suite.to_str().unwrap(),
        ])
        .assert()
        .success();
    // The recorded live target is surfaced as a comment next to the
    // cassette reference.
    let yaml = std::fs::read_to_string(&suite).unwrap();
    assert!(yaml.contains("source_command"), "{yaml}");

    mcptest()
        .current_dir(dir.path())
        .env_remove("CI")
        .args(["run", "--config", suite.to_str().unwrap()])
        .assert()
        .success()
        .stdout(predicate::str::contains("1 passed"));
}
