//! Verify a recorded cassette against a live response stream.
//!
//! The `mcptest verify --cassette <path> --server <command|url>` flow
//! replays each recorded request, captures the live response, then
//! diffs the two envelopes through this module. The strategies match
//! the replay engine in [`super::portable`]: `Strict` compares JSON
//! byte-for-byte, `Semantic` runs both sides through the
//! [`super::Normalizer`] so timestamps and UUIDs no longer break the
//! compare, and `Structural` only checks shape (keys present, types
//! agree, array lengths match).

use serde_json::Value;

use super::Normalizer;
use crate::protocol::JsonRpcMessage;

/// Replay strategy applied to the response side of every exchange.
///
/// The request side is matched by method + params (see
/// [`super::portable::replay`]) which is the same across strategies; this
/// enum only affects how the verifier reports response drift.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ReplayStrategy {
    /// Byte-exact JSON equality. Use for fully deterministic servers
    /// (synthetic mocks, deterministic fixtures).
    Strict,
    /// Default. Normalizes timestamps, UUIDs, ETags, trace IDs, and
    /// Unix epochs on both sides before comparing.
    #[default]
    Semantic,
    /// Compares shape only (keys present, type of leaves, array
    /// lengths). Useful when the server returns volatile content but
    /// stable structure.
    Structural,
}

/// One verifier delta. `path` is a JSON Pointer (RFC 6901) into the
/// response that differs; `reason` is a short, human-readable
/// description suitable for printing in CLI output.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Delta {
    /// JSON Pointer to the differing value, e.g. `/result/items/0/name`.
    /// Empty string means the document root.
    pub path: String,
    /// One-line reason a reader can act on.
    pub reason: String,
}

/// Compare a recorded response envelope against a live one.
///
/// Returns an empty `Vec` when the two envelopes agree under the
/// supplied strategy; otherwise each entry describes one delta.
pub fn verify(
    recorded: &JsonRpcMessage,
    actual: &JsonRpcMessage,
    strategy: ReplayStrategy,
) -> Vec<Delta> {
    let mut out = Vec::new();
    diff_method(recorded, actual, &mut out);
    let rec_json = envelope_to_json(recorded);
    let act_json = envelope_to_json(actual);
    diff_value(&rec_json, &act_json, "", strategy, &mut out);
    out
}

fn diff_method(recorded: &JsonRpcMessage, actual: &JsonRpcMessage, out: &mut Vec<Delta>) {
    if recorded.method != actual.method {
        out.push(Delta {
            path: "/method".to_string(),
            reason: format!(
                "method changed: recorded {:?}, actual {:?}",
                recorded.method, actual.method
            ),
        });
    }
}

fn envelope_to_json(msg: &JsonRpcMessage) -> Value {
    // Compare the response-side payload only. `id` is supplied by the
    // client and is never stable across runs, so we strip it before
    // diffing. `method` is checked separately in [`diff_method`].
    let mut map = serde_json::Map::new();
    if let Some(p) = &msg.params {
        map.insert("params".to_string(), p.clone());
    }
    if let Some(r) = &msg.result {
        map.insert("result".to_string(), r.clone());
    }
    if let Some(e) = &msg.error {
        map.insert(
            "error".to_string(),
            serde_json::json!({
                "code": e.code,
                "message": e.message,
                "data": e.data,
            }),
        );
    }
    Value::Object(map)
}

fn diff_value(
    recorded: &Value,
    actual: &Value,
    path: &str,
    strategy: ReplayStrategy,
    out: &mut Vec<Delta>,
) {
    match strategy {
        ReplayStrategy::Strict => diff_strict(recorded, actual, path, out),
        ReplayStrategy::Semantic => diff_semantic(recorded, actual, path, out),
        ReplayStrategy::Structural => diff_structural(recorded, actual, path, out),
    }
}

fn diff_strict(recorded: &Value, actual: &Value, path: &str, out: &mut Vec<Delta>) {
    if recorded == actual {
        return;
    }
    match (recorded, actual) {
        (Value::Object(r), Value::Object(a)) => diff_object_strict(r, a, path, out),
        (Value::Array(r), Value::Array(a)) => diff_array(r, a, path, ReplayStrategy::Strict, out),
        _ => out.push(Delta {
            path: pointer_or_root(path),
            reason: format!("value changed: recorded {recorded}, actual {actual}"),
        }),
    }
}

fn diff_object_strict(
    recorded: &serde_json::Map<String, Value>,
    actual: &serde_json::Map<String, Value>,
    path: &str,
    out: &mut Vec<Delta>,
) {
    for (k, rv) in recorded {
        let child = format!("{}/{}", path, escape_pointer(k));
        match actual.get(k) {
            None => out.push(Delta {
                path: child,
                reason: "key missing from actual".to_string(),
            }),
            Some(av) => diff_strict(rv, av, &child, out),
        }
    }
    for k in actual.keys() {
        if !recorded.contains_key(k) {
            out.push(Delta {
                path: format!("{}/{}", path, escape_pointer(k)),
                reason: "key absent from recorded but present in actual".to_string(),
            });
        }
    }
}

fn diff_semantic(recorded: &Value, actual: &Value, path: &str, out: &mut Vec<Delta>) {
    let n = Normalizer::default();
    let recorded_norm = n.normalize(recorded).value;
    let actual_norm = n.normalize(actual).value;
    if n.matches(&recorded_norm, &actual_norm) {
        return;
    }
    // Fall back to a strict-diff on the normalized values so we still
    // produce a pointer-aware delta list, not just a single root entry.
    diff_strict(&recorded_norm, &actual_norm, path, out);
}

fn diff_structural(recorded: &Value, actual: &Value, path: &str, out: &mut Vec<Delta>) {
    if !same_shape(recorded, actual) {
        out.push(Delta {
            path: pointer_or_root(path),
            reason: format!(
                "shape changed: recorded {}, actual {}",
                shape_name(recorded),
                shape_name(actual)
            ),
        });
        return;
    }
    match (recorded, actual) {
        (Value::Object(r), Value::Object(a)) => {
            for (k, rv) in r {
                let child = format!("{}/{}", path, escape_pointer(k));
                match a.get(k) {
                    None => out.push(Delta {
                        path: child,
                        reason: "key missing from actual".to_string(),
                    }),
                    Some(av) => diff_structural(rv, av, &child, out),
                }
            }
            for k in a.keys() {
                if !r.contains_key(k) {
                    out.push(Delta {
                        path: format!("{}/{}", path, escape_pointer(k)),
                        reason: "key absent from recorded but present in actual".to_string(),
                    });
                }
            }
        }
        (Value::Array(r), Value::Array(a)) => {
            diff_array(r, a, path, ReplayStrategy::Structural, out);
        }
        _ => {}
    }
}

fn diff_array(
    recorded: &[Value],
    actual: &[Value],
    path: &str,
    strategy: ReplayStrategy,
    out: &mut Vec<Delta>,
) {
    if recorded.len() != actual.len() {
        out.push(Delta {
            path: pointer_or_root(path),
            reason: format!(
                "array length changed: recorded {}, actual {}",
                recorded.len(),
                actual.len()
            ),
        });
        return;
    }
    for (i, (rv, av)) in recorded.iter().zip(actual.iter()).enumerate() {
        let child = format!("{path}/{i}");
        diff_value(rv, av, &child, strategy, out);
    }
}

fn same_shape(a: &Value, b: &Value) -> bool {
    matches!(
        (a, b),
        (Value::Null, Value::Null)
            | (Value::Bool(_), Value::Bool(_))
            | (Value::Number(_), Value::Number(_))
            | (Value::String(_), Value::String(_))
            | (Value::Array(_), Value::Array(_))
            | (Value::Object(_), Value::Object(_))
    )
}

fn shape_name(v: &Value) -> &'static str {
    match v {
        Value::Null => "null",
        Value::Bool(_) => "bool",
        Value::Number(_) => "number",
        Value::String(_) => "string",
        Value::Array(_) => "array",
        Value::Object(_) => "object",
    }
}

fn pointer_or_root(path: &str) -> String {
    if path.is_empty() {
        "".to_string()
    } else {
        path.to_string()
    }
}

fn escape_pointer(key: &str) -> String {
    key.replace('~', "~0").replace('/', "~1")
}

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

    fn recorded_ok() -> JsonRpcMessage {
        JsonRpcMessage::response(
            JsonRpcId::Number(1),
            json!({"items": [1, 2, 3], "name": "alpha"}),
        )
    }

    #[test]
    fn semantic_strategy_passes_when_only_volatile_fields_differ() {
        // Both UUIDs must satisfy the v4 regex used by the normalizer
        // (variant byte 8, 9, a, or b in the third group).
        let rec = JsonRpcMessage::response(
            JsonRpcId::Number(1),
            json!({"id": "550e8400-e29b-41d4-a716-446655440000", "at": "2026-05-15T12:34:56Z"}),
        );
        let act = JsonRpcMessage::response(
            JsonRpcId::Number(2),
            json!({"id": "11111111-2222-4222-8222-555555555555", "at": "2026-06-01T00:00:00Z"}),
        );
        let deltas = verify(&rec, &act, ReplayStrategy::Semantic);
        assert!(
            deltas.is_empty(),
            "semantic should ignore volatile values: {deltas:?}"
        );
    }

    #[test]
    fn strict_strategy_flags_any_value_change() {
        let rec = recorded_ok();
        let mut act = recorded_ok();
        act.result = Some(json!({"items": [1, 2, 3], "name": "beta"}));
        let deltas = verify(&rec, &act, ReplayStrategy::Strict);
        assert_eq!(deltas.len(), 1);
        assert!(deltas[0].path.contains("name"));
    }

    #[test]
    fn structural_strategy_ignores_value_changes_within_same_shape() {
        let rec = recorded_ok();
        let mut act = recorded_ok();
        act.result = Some(json!({"items": [99, 100, 101], "name": "totally-different"}));
        let deltas = verify(&rec, &act, ReplayStrategy::Structural);
        assert!(
            deltas.is_empty(),
            "structural must accept changed values when shape agrees: {deltas:?}"
        );
    }

    #[test]
    fn structural_strategy_flags_shape_change() {
        let rec = recorded_ok();
        let mut act = recorded_ok();
        act.result = Some(json!({"items": "not-an-array", "name": "alpha"}));
        let deltas = verify(&rec, &act, ReplayStrategy::Structural);
        assert!(!deltas.is_empty());
        assert!(deltas[0].reason.contains("shape changed"));
    }

    #[test]
    fn structural_strategy_flags_array_length_change() {
        let rec = recorded_ok();
        let mut act = recorded_ok();
        act.result = Some(json!({"items": [1, 2], "name": "alpha"}));
        let deltas = verify(&rec, &act, ReplayStrategy::Structural);
        assert!(deltas.iter().any(|d| d.reason.contains("array length")));
    }

    #[test]
    fn method_change_is_always_reported_regardless_of_strategy() {
        let rec = JsonRpcMessage::request(JsonRpcId::Number(1), "tools/list", json!({}));
        let act = JsonRpcMessage::request(JsonRpcId::Number(1), "tools/call", json!({}));
        for s in [
            ReplayStrategy::Strict,
            ReplayStrategy::Semantic,
            ReplayStrategy::Structural,
        ] {
            let deltas = verify(&rec, &act, s);
            assert!(
                deltas.iter().any(|d| d.path == "/method"),
                "{s:?} missed method drift"
            );
        }
    }

    #[test]
    fn empty_delta_list_means_equivalent_envelopes() {
        let rec = recorded_ok();
        let act = recorded_ok();
        for s in [
            ReplayStrategy::Strict,
            ReplayStrategy::Semantic,
            ReplayStrategy::Structural,
        ] {
            assert!(
                verify(&rec, &act, s).is_empty(),
                "{s:?} returned spurious deltas"
            );
        }
    }
}
