//! Transport-agnostic cassette format.
//!
//! Why this exists: a recorded cassette has to replay against either a
//! stdio MCP server or an HTTP MCP server without modification. The
//! recorded payload is the JSON-RPC envelope itself, never the bytes
//! that wrapped it on the wire. This module owns the in-memory cassette
//! representation that the recorder will eventually serialize and the
//! replayer will eventually consume.
//!
//! ## Shape
//!
//! A [`Cassette`] is an ordered list of [`Exchange`]s plus a `meta` map
//! for non-replay diagnostics (recording timestamp, mcptest version,
//! redactions applied). The wire payloads live on [`Exchange::request`]
//! and [`Exchange::response`] as parsed [`JsonRpcMessage`] values. There
//! is no slot for HTTP headers, stdio framing, content-type, or any other
//! transport-specific field, by construction.
//!
//! See [`replay`] for the shared replay loop that both the stdio and HTTP
//! transports plug into.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

use crate::protocol::JsonRpcMessage;
use crate::redaction::Redactor;

/// Wire-format version embedded in every cassette file. Bumped only when
/// the in-memory shape below changes in a backwards-incompatible way.
pub const CASSETTE_FORMAT_VERSION: &str = "1";

/// One recorded request/response pair.
///
/// Both sides are parsed JSON-RPC envelopes. The intent is that any
/// transport (stdio, HTTP, future websocket) can replay the cassette by
/// pulling the next [`JsonRpcMessage`] off [`Cassette::exchanges`] and
/// echoing it back. Nothing on this struct describes how the bytes were
/// framed on the wire, which is the entire point.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Exchange {
    /// JSON-RPC envelope the client sent.
    pub request: JsonRpcMessage,
    /// JSON-RPC envelope the server returned. May be a successful
    /// response, an error response, or a notification depending on the
    /// recorded interaction.
    pub response: JsonRpcMessage,
}

/// Transport-agnostic cassette suitable for stdio and HTTP replay alike.
///
/// The `meta` map is intentionally a `BTreeMap<String, String>` so a
/// reviewer can diff two cassettes line-by-line without YAML key-order
/// noise. It carries observational diagnostics (recorder version,
/// recorded-at timestamp, redaction policy) and never anything the
/// replay loop reads. Any field the replay loop consumes lives on
/// [`Exchange`] instead.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct Cassette {
    /// Format version of this cassette. Equal to
    /// [`CASSETTE_FORMAT_VERSION`] for cassettes this build can replay.
    pub version: String,
    /// Version of the `mcptest` binary that wrote the cassette.
    /// Populated automatically by [`Cassette::new`] via
    /// [`crate::version::full`]. Reviewers and loaders compare this
    /// against the running build to spot stale recordings; the field is
    /// `Option` so cassettes written by older builds still deserialize.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub mcptest_version: Option<String>,
    /// Diagnostic metadata not consumed during replay.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub meta: BTreeMap<String, String>,
    /// Ordered list of request/response pairs.
    pub exchanges: Vec<Exchange>,
}

impl Cassette {
    /// Construct an empty cassette stamped with the current
    /// [`CASSETTE_FORMAT_VERSION`] and the running build's
    /// [`crate::version::full`] string.
    pub fn new() -> Self {
        Self {
            version: CASSETTE_FORMAT_VERSION.to_string(),
            mcptest_version: Some(crate::version::full().to_string()),
            meta: BTreeMap::new(),
            exchanges: Vec::new(),
        }
    }

    /// Append an exchange to the cassette.
    pub fn push(&mut self, exchange: Exchange) {
        self.exchanges.push(exchange);
    }

    /// Walk every exchange through the supplied [`Redactor`]. Useful for
    /// the recorder's "scrub on save" step so cassettes never carry
    /// secrets to disk. Returns a new cassette so callers can keep an
    /// un-redacted copy in memory for diagnostics if they need to.
    ///
    /// Why this lives here:  already centralises secret detection
    /// in [`Redactor::redact_json`]. The cassette path walks the
    /// JSON-RPC `params`, `result`, and error `data` fields through that
    /// API so cassettes inherit one redaction policy without growing a
    /// second one of their own.
    pub fn redacted(&self, redactor: &Redactor) -> Self {
        let exchanges = self
            .exchanges
            .iter()
            .map(|e| Exchange {
                request: redact_envelope(&e.request, redactor),
                response: redact_envelope(&e.response, redactor),
            })
            .collect();
        Self {
            version: self.version.clone(),
            mcptest_version: self.mcptest_version.clone(),
            meta: self.meta.clone(),
            exchanges,
        }
    }
}

impl Default for Cassette {
    fn default() -> Self {
        Self::new()
    }
}

fn redact_envelope(msg: &JsonRpcMessage, r: &Redactor) -> JsonRpcMessage {
    JsonRpcMessage {
        jsonrpc: msg.jsonrpc.clone(),
        id: msg.id.clone(),
        method: msg.method.clone(),
        params: msg.params.as_ref().map(|v| r.redact_json(v)),
        result: msg.result.as_ref().map(|v| r.redact_json(v)),
        error: msg.error.as_ref().map(|e| {
            let mut copy = e.clone();
            copy.data = copy.data.as_ref().map(|d| r.redact_json(d));
            copy
        }),
    }
}

/// Outcome of replaying one exchange against a cassette.
///
/// `Match` means the live request matched the recorded envelope and the
/// caller should return the recorded response. `Mismatch` means the live
/// request did not line up with the next recorded exchange; the caller
/// decides whether to fail the test or fall through to a live call.
#[derive(Debug, Clone, PartialEq)]
pub enum ReplayOutcome {
    /// Recorded response to hand back to the caller.
    Match(JsonRpcMessage),
    /// Live request did not match the next recorded request. Carries
    /// both envelopes for diagnostic surfacing.
    Mismatch {
        /// Recorded request that was expected next.
        expected: JsonRpcMessage,
        /// Live request the transport saw instead.
        actual: JsonRpcMessage,
    },
    /// Cassette exhausted: no more recorded exchanges remain.
    Exhausted,
}

/// Replay one live request against a cassette cursor.
///
/// The cursor (`next_index`) is owned by the transport so this function
/// stays pure: same inputs produce the same outputs. Both the stdio and
/// HTTP transports call into this helper so a cassette that replays
/// correctly through one transport replays identically through the
/// other. That tautology is the point.
///
/// Defaults to [`super::ReplayStrategy::Semantic`]. Use
/// [`replay_with_strategy`] when you need byte-exact or shape-only
/// matching of requests.
pub fn replay(
    cassette: &Cassette,
    next_index: usize,
    live_request: &JsonRpcMessage,
) -> ReplayOutcome {
    replay_with_strategy(
        cassette,
        next_index,
        live_request,
        super::ReplayStrategy::Semantic,
    )
}

/// Replay variant that picks the request-matching strategy.
///
/// `Strict` requires `method` and `params` to be byte-for-byte equal.
/// `Semantic` runs both sides through the [`super::Normalizer`] before
/// comparing so placeholder-tagged volatile values still match.
/// `Structural` accepts the request when method matches and `params`
/// have the same JSON shape (object vs array, same keys, matching
/// types) regardless of leaf values.
pub fn replay_with_strategy(
    cassette: &Cassette,
    next_index: usize,
    live_request: &JsonRpcMessage,
    strategy: super::ReplayStrategy,
) -> ReplayOutcome {
    let Some(exchange) = cassette.exchanges.get(next_index) else {
        return ReplayOutcome::Exhausted;
    };
    if request_matches(&exchange.request, live_request, strategy) {
        ReplayOutcome::Match(exchange.response.clone())
    } else {
        ReplayOutcome::Mismatch {
            expected: exchange.request.clone(),
            actual: live_request.clone(),
        }
    }
}

fn request_matches(
    recorded: &JsonRpcMessage,
    live: &JsonRpcMessage,
    strategy: super::ReplayStrategy,
) -> bool {
    // We match on `method` plus `params`. The `id` is supplied by the
    // client and is not stable across runs, so it is deliberately not
    // part of the equality check. `jsonrpc` is constant.
    if recorded.method != live.method {
        return false;
    }
    match strategy {
        super::ReplayStrategy::Strict => recorded.params == live.params,
        super::ReplayStrategy::Semantic => {
            // Use the same normalizer as the response-side comparator so
            // volatile inputs (timestamps in tool args, request UUIDs)
            // still match the recorded envelope.
            let n = super::Normalizer::default();
            match (&recorded.params, &live.params) {
                (None, None) => true,
                (Some(r), Some(a)) => n.matches(&n.normalize(r).value, &n.normalize(a).value),
                _ => false,
            }
        }
        super::ReplayStrategy::Structural => params_share_shape(&recorded.params, &live.params),
    }
}

fn params_share_shape(a: &Option<serde_json::Value>, b: &Option<serde_json::Value>) -> bool {
    match (a, b) {
        (None, None) => true,
        (Some(av), Some(bv)) => json_shape_equal(av, bv),
        _ => false,
    }
}

fn json_shape_equal(a: &serde_json::Value, b: &serde_json::Value) -> bool {
    use serde_json::Value;
    match (a, b) {
        (Value::Null, Value::Null) => true,
        (Value::Bool(_), Value::Bool(_)) => true,
        (Value::Number(_), Value::Number(_)) => true,
        (Value::String(_), Value::String(_)) => true,
        (Value::Array(ax), Value::Array(bx)) => {
            ax.len() == bx.len()
                && ax
                    .iter()
                    .zip(bx.iter())
                    .all(|(x, y)| json_shape_equal(x, y))
        }
        (Value::Object(ax), Value::Object(bx)) => {
            ax.len() == bx.len()
                && ax
                    .iter()
                    .all(|(k, v)| bx.get(k).is_some_and(|other| json_shape_equal(v, other)))
        }
        _ => false,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::protocol::JsonRpcId;
    use serde_json::json;

    fn handshake_init() -> Exchange {
        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": {}}),
            ),
        }
    }

    fn tool_call(id: i64, tool: &str) -> Exchange {
        Exchange {
            request: JsonRpcMessage::request(
                JsonRpcId::Number(id),
                "tools/call",
                json!({"name": tool}),
            ),
            response: JsonRpcMessage::response(
                JsonRpcId::Number(id),
                json!({"content": [{"type": "text", "text": "ok"}]}),
            ),
        }
    }

    #[test]
    fn new_cassette_stamps_running_mcptest_version() {
        let c = Cassette::new();
        let stamped = c.mcptest_version.expect("version stamped");
        assert!(!stamped.is_empty());
        assert_eq!(stamped, crate::version::full());
    }

    #[test]
    fn legacy_cassette_without_mcptest_version_still_loads() {
        // Older cassettes do not carry the field. The loader must accept
        // them so existing recordings keep replaying after the upgrade.
        let raw = r#"{"version":"1","exchanges":[]}"#;
        let back: Cassette = serde_json::from_str(raw).expect("legacy load");
        assert!(back.mcptest_version.is_none());
        assert!(back.exchanges.is_empty());
    }

    #[test]
    fn empty_cassette_round_trips_through_json() {
        let c = Cassette::new();
        let s = serde_json::to_string(&c).expect("serializes");
        let back: Cassette = serde_json::from_str(&s).expect("deserializes");
        assert_eq!(c, back);
    }

    #[test]
    fn populated_cassette_round_trips_through_json() {
        let mut c = Cassette::new();
        c.push(handshake_init());
        c.push(tool_call(2, "search"));
        let s = serde_json::to_string(&c).expect("serializes");
        let back: Cassette = serde_json::from_str(&s).expect("deserializes");
        assert_eq!(c, back);
    }

    #[test]
    fn replay_returns_recorded_response_when_request_matches() {
        let mut c = Cassette::new();
        c.push(handshake_init());
        let live = JsonRpcMessage::request(
            JsonRpcId::Number(99),
            "initialize",
            json!({"protocolVersion": "2025-06-18"}),
        );
        match replay(&c, 0, &live) {
            ReplayOutcome::Match(resp) => {
                assert_eq!(
                    resp.result.expect("result"),
                    json!({
                        "protocolVersion": "2025-06-18",
                        "capabilities": {},
                    })
                );
            }
            other => panic!("expected match, got {other:?}"),
        }
    }

    #[test]
    fn replay_detects_method_mismatch() {
        let mut c = Cassette::new();
        c.push(handshake_init());
        let live = JsonRpcMessage::request(JsonRpcId::Number(1), "tools/list", json!({}));
        match replay(&c, 0, &live) {
            ReplayOutcome::Mismatch { expected, actual } => {
                assert_eq!(expected.method.as_deref(), Some("initialize"));
                assert_eq!(actual.method.as_deref(), Some("tools/list"));
            }
            other => panic!("expected mismatch, got {other:?}"),
        }
    }

    #[test]
    fn replay_reports_exhaustion_past_last_exchange() {
        let mut c = Cassette::new();
        c.push(handshake_init());
        let live = JsonRpcMessage::request(JsonRpcId::Number(2), "tools/list", json!({}));
        assert_eq!(replay(&c, 1, &live), ReplayOutcome::Exhausted);
    }

    #[test]
    fn redacted_replaces_authorization_in_params() {
        let mut c = Cassette::new();
        c.push(Exchange {
            request: JsonRpcMessage::request(
                JsonRpcId::Number(1),
                "tools/call",
                json!({"name": "search", "arguments": {"api_key": "sk-abc1234567890DEF"}}),
            ),
            response: JsonRpcMessage::response(JsonRpcId::Number(1), json!({"ok": true})),
        });
        let r = Redactor::new();
        let out = c.redacted(&r);
        let params = out.exchanges[0]
            .request
            .params
            .as_ref()
            .expect("params present");
        let arg = &params["arguments"]["api_key"];
        assert!(
            arg.as_str().expect("string").starts_with("[REDACTED:"),
            "api_key should be redacted, got {arg}"
        );
    }
}
