//! Behavior tests for cassette distillation.

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

use serde_json::{json, Value};

use super::*;
use crate::cassette::Exchange;
use crate::protocol::{JsonRpcId, JsonRpcMessage};

fn opts() -> DistillOptions {
    DistillOptions {
        cassette_path: "./tape.json".to_string(),
        server_name: "recorded".to_string(),
    }
}

/// Build a server cassette from `(method, params, result)` triples.
fn server_cassette(exchanges: &[(&str, Value, Value)]) -> ParsedCassette {
    let mut c = Cassette::new();
    for (i, (method, params, result)) in exchanges.iter().enumerate() {
        let id = JsonRpcId::Number(i as i64 + 1);
        c.push(Exchange {
            request: JsonRpcMessage::request(id.clone(), *method, params.clone()),
            response: JsonRpcMessage::response(id, result.clone()),
        });
    }
    ParsedCassette::Server(c)
}

fn tool_call(tool: &str, args: Value, text: &str) -> (&'static str, Value, Value) {
    (
        "tools/call",
        json!({ "name": tool, "arguments": args }),
        json!({ "isError": false, "content": [{ "type": "text", "text": text }] }),
    )
}

fn init_exchange() -> (&'static str, Value, Value) {
    (
        "initialize",
        json!({}),
        json!({ "protocolVersion": "2025-03-26", "capabilities": {} }),
    )
}

/// Parse the generated suite into JSON for structural assertions.
fn suite_json(yaml: &str) -> 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_and_ordinal_names() {
    let cassette = server_cassette(&[
        init_exchange(),
        tool_call("echo", json!({ "msg": "one" }), "one"),
        tool_call("search", json!({ "q": "x", "limit": 3 }), "hit"),
    ]);
    let suite = distill(&cassette, &opts()).expect("distills");
    let doc = suite_json(&suite.yaml);
    let tools = doc["tools"].as_array().expect("tools list");
    assert_eq!(tools.len(), 2);
    assert_eq!(tools[0]["name"], "echo: distilled from tape #1");
    assert_eq!(tools[0]["server"], "recorded");
    assert_eq!(tools[0]["tool"], "echo");
    assert_eq!(tools[0]["args"], json!({ "msg": "one" }));
    assert_eq!(tools[1]["name"], "search: distilled from tape #2");
    assert_eq!(tools[1]["args"], json!({ "q": "x", "limit": 3 }));
    // Metadata mirrors the YAML.
    assert_eq!(suite.tests.len(), 2);
    assert_eq!(suite.tests[1].tool, "search");
    assert_eq!(suite.tests[1].source_index, 2);
    // The cassette points back at itself for offline replay.
    assert_eq!(doc["servers"]["recorded"]["cassette"], "./tape.json");
}

#[test]
fn repeated_identical_calls_yield_exact_value_assertions_and_singles_do_not() {
    let cassette = server_cassette(&[
        init_exchange(),
        tool_call("echo", json!({ "msg": "hi" }), "hi"),
        tool_call("echo", json!({ "msg": "hi" }), "hi"),
        tool_call("version", json!({}), "1.2.3"),
    ]);
    let suite = distill(&cassette, &opts()).expect("distills");
    let doc = suite_json(&suite.yaml);
    let tools = doc["tools"].as_array().expect("tools list");
    let exact_targets = |t: &Value| -> Vec<String> {
        t["expect"]["assertions"]
            .as_array()
            .expect("assertions list")
            .iter()
            .filter(|e| e["matcher"].get("exact").is_some())
            .filter_map(|e| e["target"].as_str().map(str::to_string))
            .filter(|target| target != "result.isError")
            .collect()
    };
    // The repeated pair acts as its own stability probe.
    assert!(
        exact_targets(&tools[0]).contains(&"result.content[0].text".to_string()),
        "repeat should earn an exact assertion: {}",
        suite.yaml
    );
    assert!(!exact_targets(&tools[1]).is_empty());
    // The single observation keeps invariant + schema only.
    assert!(
        exact_targets(&tools[2]).is_empty(),
        "single call must not earn exact assertions: {}",
        suite.yaml
    );
}

#[test]
fn args_differing_only_in_values_are_not_treated_as_repeats() {
    let cassette = server_cassette(&[
        init_exchange(),
        tool_call("echo", json!({ "msg": "a" }), "a"),
        tool_call("echo", json!({ "msg": "b" }), "b"),
    ]);
    let suite = distill(&cassette, &opts()).expect("distills");
    let doc = suite_json(&suite.yaml);
    for t in doc["tools"].as_array().expect("tools") {
        let exacts: Vec<&Value> = t["expect"]["assertions"]
            .as_array()
            .expect("assertions")
            .iter()
            .filter(|e| e["matcher"].get("exact").is_some() && e["target"] != "result.isError")
            .collect();
        assert!(exacts.is_empty(), "distinct args are single observations");
    }
}

#[test]
fn review_comment_sits_directly_above_only_the_flagged_tests() {
    let cassette = server_cassette(&[
        init_exchange(),
        tool_call("echo", json!({ "msg": "plain" }), "plain"),
        tool_call(
            "invite",
            json!({ "address": "ada.lovelace@acme-corp.io" }),
            "sent",
        ),
        // The key is deliberately not secret-named: a secret-named key
        // ("token", "api_key", ...) is already scrubbed by the redactor,
        // and the review pass exists for what slips past it.
        tool_call(
            "fetch",
            json!({ "cursor": "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0" }),
            "ok",
        ),
    ]);
    let suite = distill(&cassette, &opts()).expect("distills");
    let lines: Vec<&str> = suite.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}` not found in:\n{}", suite.yaml));
        lines[idx - 1]
    };
    assert!(
        !preceding("echo: distilled from tape #1").contains("# review:"),
        "clean test must not be flagged"
    );
    assert_eq!(
        preceding("invite: distilled from tape #2").trim(),
        REVIEW_COMMENT.trim()
    );
    assert_eq!(
        preceding("fetch: distilled from tape #3").trim(),
        REVIEW_COMMENT.trim()
    );
    // Flags name the location and category.
    assert_eq!(suite.review_flags.len(), 2);
    assert_eq!(suite.review_flags[0].test, "invite: distilled from tape #2");
    assert_eq!(suite.review_flags[0].path, "args.address");
    assert_eq!(suite.review_flags[0].category, ReviewCategory::Email);
    assert_eq!(suite.review_flags[1].path, "args.cursor");
    assert_eq!(suite.review_flags[1].category, ReviewCategory::Token);
}

#[test]
fn exact_assertions_carrying_an_email_are_flagged_by_target_path() {
    // The email sits in the response; the repeat makes it an exact
    // assertion, which is exactly what must be flagged.
    let cassette = server_cassette(&[
        init_exchange(),
        tool_call("whoami", json!({}), "you are kim.x@acme-corp.io"),
        tool_call("whoami", json!({}), "you are kim.x@acme-corp.io"),
    ]);
    let suite = distill(&cassette, &opts()).expect("distills");
    assert!(
        suite
            .review_flags
            .iter()
            .any(|f| f.path == "result.content[0].text" && f.category == ReviewCategory::Email),
        "flags: {:?}",
        suite.review_flags
    );
}

#[test]
fn skipped_exchanges_are_summarized_in_a_header_comment() {
    let cassette = server_cassette(&[
        init_exchange(),
        ("tools/list", json!({}), json!({ "tools": [] })),
        tool_call("echo", json!({}), "hi"),
    ]);
    let suite = distill(&cassette, &opts()).expect("distills");
    assert!(
        suite
            .yaml
            .contains("# Exchanges not distilled: #0 initialize, #1 tools/list"),
        "got:\n{}",
        suite.yaml
    );
}

#[test]
fn cassette_with_no_tool_calls_is_a_clear_error() {
    let cassette = server_cassette(&[init_exchange()]);
    let err = distill(&cassette, &opts()).expect_err("nothing to distill");
    assert!(matches!(err, DistillError::NoToolCalls));
}

#[test]
fn parse_cassette_detects_both_shapes_and_rejects_unknown_json() {
    let server = r#"{ "version": "1", "exchanges": [] }"#;
    assert!(matches!(
        parse_cassette(server),
        Ok(ParsedCassette::Server(_))
    ));
    let agent = r#"{
        "schema_version": "v1", "agent_name": "a", "model": "m",
        "user_prompt": "p", "recorded_at": "2026-01-01T00:00:00Z",
        "trace": { "tool_calls": [], "tool_results": [],
                   "final_response": "",
                   "conversation": { "tokens": { "prompt": 0, "completion": 0, "total": 0 },
                                     "duration_ms": 0, "message_count": 0, "turns": [] } }
    }"#;
    assert!(matches!(
        parse_cassette(agent),
        Ok(ParsedCassette::Agent(_))
    ));
    assert!(matches!(
        parse_cassette(r#"{ "neither": true }"#),
        Err(DistillError::UnknownShape)
    ));
    assert!(matches!(
        parse_cassette("not json"),
        Err(DistillError::Parse(_))
    ));
}

#[test]
fn agent_recording_distills_to_a_retarget_template_against_recorded_servers() {
    let raw = json!({
        "schema_version": "v1",
        "agent_name": "weather query",
        "model": "claude-sonnet-4-5",
        "user_prompt": "weather?",
        "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" }] } }
            ],
            "final_response": "72 F",
            "conversation": { "tokens": { "prompt": 1, "completion": 1, "total": 2 },
                              "duration_ms": 5, "message_count": 2, "turns": [] }
        }
    });
    let cassette = parse_cassette(&raw.to_string()).expect("agent cassette parses");
    let suite = distill(&cassette, &opts()).expect("distills");
    let doc = suite_json(&suite.yaml);
    // The test targets the server key the cassette recorded.
    assert_eq!(doc["tools"][0]["server"], "weather");
    assert_eq!(doc["tools"][0]["args"], json!({ "city": "Sacramento" }));
    // The servers block is a placeholder the user must retarget.
    assert!(suite.yaml.contains("TODO(retarget)"), "{}", suite.yaml);
    assert!(doc["servers"]["weather"]["command"].is_array());
    assert!(suite.notes.iter().any(|n| n.contains("retarget")));
}

fn workspace_root() -> PathBuf {
    // CARGO_MANIFEST_DIR is crates/mcptest-core; two parents up is root.
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .parent()
        .and_then(Path::parent)
        .expect("workspace root above crates/mcptest-core")
        .to_path_buf()
}

fn assert_validates(yaml: &str) {
    let schema_path = workspace_root().join("schemas").join("v1.json");
    let raw = std::fs::read_to_string(&schema_path)
        .unwrap_or_else(|e| panic!("read {}: {e}", schema_path.display()));
    let schema: Value = serde_json::from_str(&raw).expect("v1.json is valid JSON");
    let validator = jsonschema::validator_for(&schema).expect("schema compiles");
    let as_json = suite_json(yaml);
    let errors: Vec<String> = validator
        .iter_errors(&as_json)
        .map(|e| e.to_string())
        .collect();
    assert!(errors.is_empty(), "suite should validate, got: {errors:#?}");
}

#[test]
fn distilled_server_suite_validates_against_the_v1_schema() {
    let cassette = server_cassette(&[
        init_exchange(),
        tool_call("echo", json!({ "msg": "hi" }), "hi"),
        tool_call("echo", json!({ "msg": "hi" }), "hi"),
        tool_call("invite", json!({ "address": "kim@acme-corp.io" }), "sent"),
    ]);
    let suite = distill(&cassette, &opts()).expect("distills");
    assert_validates(&suite.yaml);
}

#[test]
fn distilled_agent_template_validates_against_the_v1_schema() {
    let raw = json!({
        "schema_version": "v1", "agent_name": "a", "model": "m",
        "user_prompt": "p", "recorded_at": "2026-01-01T00:00:00Z",
        "trace": {
            "tool_calls": [{ "name": "echo", "server": "srv", "args": { "msg": "hi" } }],
            "tool_results": [{ "call_index": 0, "is_error": false,
                               "result": { "content": [{ "type": "text", "text": "hi" }] } }],
            "final_response": "hi",
            "conversation": { "tokens": { "prompt": 1, "completion": 1, "total": 2 },
                              "duration_ms": 5, "message_count": 2, "turns": [] }
        }
    });
    let cassette = parse_cassette(&raw.to_string()).expect("parses");
    let suite = distill(&cassette, &opts()).expect("distills");
    assert_validates(&suite.yaml);
}

#[test]
fn unique_name_appends_a_counter_on_collision() {
    let mut used = std::collections::BTreeSet::new();
    assert_eq!(unique_name("a".to_string(), &mut used), "a");
    assert_eq!(unique_name("a".to_string(), &mut used), "a (2)");
    assert_eq!(unique_name("a".to_string(), &mut used), "a (3)");
}

#[test]
fn distillation_is_deterministic_for_the_same_cassette() {
    let cassette = server_cassette(&[
        init_exchange(),
        tool_call("echo", json!({ "msg": "hi" }), "hi"),
        tool_call("echo", json!({ "msg": "hi" }), "hi"),
    ]);
    let a = distill(&cassette, &opts()).expect("distills");
    let b = distill(&cassette, &opts()).expect("distills");
    assert_eq!(a.yaml, b.yaml);
}
