//! Cassette portability verification.
//!
//! Builds a synthetic cassette in memory, round-trips it through JSON
//! serialization, then replays it against two stub "transports" (one
//! pretending to be stdio, the other HTTP) that both call into the same
//! `replay` function. Assertions:
//!
//! 1. No transport-specific field surfaces in the serialized form (no
//!    HTTP headers, no stdio framing markers, no content-type).
//! 2. The two transports observe identical replay outcomes for the
//!    same cassette and the same live request sequence.

use mcptest_core::cassette::portable::{replay, Cassette, Exchange, ReplayOutcome};
use mcptest_core::protocol::{JsonRpcId, JsonRpcMessage};
use serde_json::json;

fn synthetic_cassette() -> Cassette {
    let mut c = Cassette::new();
    c.meta.insert("recorded_by".into(), "mcptest/0.1.0".into());
    c.meta
        .insert("recorded_at".into(), "2026-05-16T00:00:00Z".into());
    c.push(Exchange {
        request: JsonRpcMessage::request(
            JsonRpcId::Number(1),
            "initialize",
            json!({"protocolVersion": "2025-06-18"}),
        ),
        response: JsonRpcMessage::response(
            JsonRpcId::Number(1),
            json!({"protocolVersion": "2025-06-18", "capabilities": {}}),
        ),
    });
    c.push(Exchange {
        request: JsonRpcMessage::request(JsonRpcId::Number(2), "tools/list", json!({})),
        response: JsonRpcMessage::response(
            JsonRpcId::Number(2),
            json!({"tools": [{"name": "search"}]}),
        ),
    });
    c.push(Exchange {
        request: JsonRpcMessage::request(
            JsonRpcId::Number(3),
            "tools/call",
            json!({"name": "search", "arguments": {"q": "mcp"}}),
        ),
        response: JsonRpcMessage::response(
            JsonRpcId::Number(3),
            json!({"content": [{"type": "text", "text": "hit"}]}),
        ),
    });
    c
}

/// Run `cassette` against a sequence of live requests. The cursor lives
/// inside this helper so the test driver is transport-agnostic by
/// construction. Both the stdio and HTTP stubs below invoke this same
/// helper.
fn drive(cassette: &Cassette, requests: &[JsonRpcMessage]) -> Vec<ReplayOutcome> {
    requests
        .iter()
        .enumerate()
        .map(|(i, r)| replay(cassette, i, r))
        .collect()
}

#[test]
fn cassette_carries_no_transport_specific_fields() {
    let c = synthetic_cassette();
    let s = serde_json::to_string(&c).expect("serializes cleanly");
    // None of the words below are transport-agnostic JSON-RPC vocabulary,
    // so seeing one means the cassette format has leaked a transport hook.
    for forbidden in [
        "Content-Type",
        "content-type",
        "Content-Length",
        "Authorization",
        "Transfer-Encoding",
        "stdio",
        "framing",
        "headers",
        "http_status",
        "status_code",
        "Set-Cookie",
        "Cookie",
    ] {
        assert!(
            !s.contains(forbidden),
            "cassette JSON leaked transport-specific token `{forbidden}`: {s}"
        );
    }
}

#[test]
fn cassette_round_trips_through_json_byte_for_byte() {
    let c = synthetic_cassette();
    let s1 = serde_json::to_string(&c).expect("serializes");
    let back: Cassette = serde_json::from_str(&s1).expect("deserializes");
    let s2 = serde_json::to_string(&back).expect("re-serializes");
    assert_eq!(c, back);
    assert_eq!(s1, s2);
}

#[test]
fn stdio_and_http_replay_paths_produce_identical_outcomes() {
    let cassette = synthetic_cassette();
    let live = [
        JsonRpcMessage::request(
            JsonRpcId::Number(101),
            "initialize",
            json!({"protocolVersion": "2025-06-18"}),
        ),
        JsonRpcMessage::request(JsonRpcId::Number(102), "tools/list", json!({})),
        JsonRpcMessage::request(
            JsonRpcId::Number(103),
            "tools/call",
            json!({"name": "search", "arguments": {"q": "mcp"}}),
        ),
    ];

    // Stub stdio transport: serialise the cassette and re-parse it the
    // way a stdio client would after reading from disk. No framing or
    // content-type is involved.
    let stdio_view: Cassette =
        serde_json::from_str(&serde_json::to_string(&cassette).expect("stdio serialise"))
            .expect("stdio parse");
    let stdio_outcomes = drive(&stdio_view, &live);

    // Stub HTTP transport: pretend the cassette came back from a
    // collector. Same serialise/parse round trip, different fictional
    // origin, same replay function.
    let http_view: Cassette =
        serde_json::from_str(&serde_json::to_string(&cassette).expect("http serialise"))
            .expect("http parse");
    let http_outcomes = drive(&http_view, &live);

    assert_eq!(stdio_outcomes.len(), http_outcomes.len());
    for (i, (a, b)) in stdio_outcomes.iter().zip(http_outcomes.iter()).enumerate() {
        assert_eq!(a, b, "transport divergence at exchange {i}");
        match a {
            ReplayOutcome::Match(resp) => {
                // Confirm the response is the one we recorded, not a
                // transport-fabricated default.
                assert!(resp.result.is_some(), "match outcome should carry result");
            }
            other => panic!("expected match at index {i}, got {other:?}"),
        }
    }
}

#[test]
fn mismatched_method_surfaces_uniformly_under_both_transports() {
    let cassette = synthetic_cassette();
    let off_script = [JsonRpcMessage::request(
        JsonRpcId::Number(999),
        "resources/list",
        json!({}),
    )];
    let stdio = drive(&cassette, &off_script);
    let http = drive(&cassette, &off_script);
    assert_eq!(stdio, http);
    match &stdio[0] {
        ReplayOutcome::Mismatch { expected, actual } => {
            assert_eq!(expected.method.as_deref(), Some("initialize"));
            assert_eq!(actual.method.as_deref(), Some("resources/list"));
        }
        other => panic!("expected mismatch, got {other:?}"),
    }
}

#[test]
fn redactor_is_wired_into_cassette_save_path() {
    use mcptest_core::redaction::Redactor;
    let mut c = Cassette::new();
    c.push(Exchange {
        request: JsonRpcMessage::request(
            JsonRpcId::Number(1),
            "tools/call",
            json!({
                "name": "github_create_issue",
                "arguments": {"token": "ghp_abcdefghijklmnop1234"},
            }),
        ),
        response: JsonRpcMessage::response(
            JsonRpcId::Number(1),
            json!({"content": [{"type": "text", "text": "ok"}]}),
        ),
    });

    let redacted = c.redacted(&Redactor::new());
    let s = serde_json::to_string(&redacted).expect("serializes");
    assert!(
        !s.contains("ghp_abcdefghijklmnop1234"),
        "secret leaked through redacted cassette: {s}"
    );
    assert!(
        s.contains("[REDACTED:"),
        "expected redaction marker in cassette: {s}"
    );
}
