//! Detection and tagging of non-deterministic substrings in cassette JSON.
//!
//! Why this exists: cassettes record real MCP server responses so the test
//! suite can replay them offline. Real responses include values that change
//! every run (timestamps, UUIDs, ETags, trace identifiers, Unix epochs). A
//! naive byte-for-byte comparison would fail on every replay. The
//! [`Normalizer`] walks a `serde_json::Value` and replaces well-known
//! non-deterministic substrings with stable placeholder tags, and it knows
//! how to compare a placeholder against a fresh real value.
//!
//! ## Storage format note
//!
//! When a value is normalized, only the placeholder is stored (for example
//! `"<UUID>"`). The original captured value is **not** preserved alongside
//! the placeholder in this ticket; the cassette file format ticket may
//! extend the schema to carry both. Keeping the v1 representation minimal
//! avoids locking in a format the runner has not yet shipped.
//!
//! ## Public surface
//!
//! - [`Normalizer`]: detector and matcher. Default config normalizes all
//!   five known kinds and uses a `0.0001` float epsilon.
//! - [`NormalizedValue`]: the result of `normalize`, the JSON value plus a
//!   debug list of replacements.
//! - [`NormalizationEntry`], [`PlaceholderKind`]: per-replacement diagnostic.

use std::collections::BTreeMap;

use serde_json::{Map, Value};

use super::patterns::{
    epoch_regex, etag_regex, is_etag_key, is_trace_key, timestamp_regex, uuid_regex,
};

/// Placeholder string substituted for ISO-8601 timestamps.
pub const TIMESTAMP_PLACEHOLDER: &str = "<TIMESTAMP>";
/// Placeholder string substituted for UUID v4 values.
pub const UUID_PLACEHOLDER: &str = "<UUID>";
/// Placeholder string substituted for plausible Unix epoch numbers.
pub const EPOCH_PLACEHOLDER: &str = "<EPOCH>";
/// Placeholder string substituted for HTTP ETag values.
pub const ETAG_PLACEHOLDER: &str = "<ETAG>";
/// Placeholder string substituted for trace, request, or correlation IDs.
pub const TRACE_ID_PLACEHOLDER: &str = "<TRACE-ID>";

/// The kind of non-deterministic value that was replaced.
///
/// Stored in [`NormalizationEntry`] so debug output can group, count, and
/// color similar replacements. `Custom` is reserved for the future runner
/// ticket that lets users register their own normalization rules.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PlaceholderKind {
    /// ISO-8601 timestamp like `2026-05-15T12:34:56.789Z`.
    Timestamp,
    /// UUID v4.
    Uuid,
    /// Unix epoch seconds or milliseconds, post-2020.
    Epoch,
    /// HTTP ETag header value.
    ETag,
    /// Trace, request, or correlation identifier.
    TraceId,
    /// User-supplied normalization rule, named at registration time.
    Custom(String),
}

impl PlaceholderKind {
    /// Returns the placeholder string this kind produces.
    pub fn placeholder(&self) -> &str {
        match self {
            PlaceholderKind::Timestamp => TIMESTAMP_PLACEHOLDER,
            PlaceholderKind::Uuid => UUID_PLACEHOLDER,
            PlaceholderKind::Epoch => EPOCH_PLACEHOLDER,
            PlaceholderKind::ETag => ETAG_PLACEHOLDER,
            PlaceholderKind::TraceId => TRACE_ID_PLACEHOLDER,
            PlaceholderKind::Custom(_) => "<CUSTOM>",
        }
    }
}

/// A single replacement performed by [`Normalizer::normalize`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NormalizationEntry {
    /// JSON Pointer (RFC 6901) to the value that was rewritten.
    pub path: String,
    /// Which kind of value was detected.
    pub kind: PlaceholderKind,
    /// The original captured value, as a string.
    pub original: String,
}

/// Wraps the normalized JSON value plus a per-replacement audit trail.
#[derive(Debug, Clone)]
pub struct NormalizedValue {
    /// JSON value with non-deterministic substrings replaced by placeholders.
    pub value: Value,
    /// Every replacement that was made, in tree-walk order. Useful for
    /// debug logging and for the future runner to surface why a replay
    /// matched.
    pub replacements: Vec<NormalizationEntry>,
}

/// Detector and comparator for cassette JSON.
///
/// Construct with [`Normalizer::default`] for production replay
/// (auto-normalization on), or with [`Normalizer::strict`] for
/// exact-equality comparisons that still respect the float epsilon.
#[derive(Debug, Clone)]
pub struct Normalizer {
    auto: bool,
    float_epsilon: f64,
    unordered_paths: Vec<String>,
}

impl Default for Normalizer {
    fn default() -> Self {
        Self {
            auto: true,
            float_epsilon: 0.0001,
            unordered_paths: Vec::new(),
        }
    }
}

impl Normalizer {
    /// Strict comparator: disables auto-normalization. Float epsilon and
    /// unordered paths still apply if configured.
    pub fn strict() -> Self {
        Self {
            auto: false,
            float_epsilon: 0.0001,
            unordered_paths: Vec::new(),
        }
    }

    /// Overrides the floating-point comparison epsilon. The default is
    /// `0.0001`, which is loose enough for `f64` round-trips through JSON
    /// but tight enough to catch real drift.
    pub fn with_float_epsilon(mut self, epsilon: f64) -> Self {
        self.float_epsilon = epsilon;
        self
    }

    /// Marks the listed JSON Pointer paths as unordered arrays. When
    /// [`matches`] encounters an array at one of these paths it compares
    /// with multiset semantics (like the `contains` matcher) rather than
    /// position-by-position.
    ///
    /// [`matches`]: Normalizer::matches
    pub fn with_unordered_paths(mut self, paths: Vec<String>) -> Self {
        self.unordered_paths = paths;
        self
    }

    /// Walks the value, replacing well-known non-deterministic substrings
    /// with placeholders. Returns the rewritten value plus the list of
    /// replacements made (for debug surfacing).
    pub fn normalize(&self, value: &Value) -> NormalizedValue {
        let mut replacements = Vec::new();
        let rewritten = if self.auto {
            walk_normalize(value, String::new(), None, &mut replacements)
        } else {
            value.clone()
        };
        NormalizedValue {
            value: rewritten,
            replacements,
        }
    }

    /// Compares a recorded value (which may carry placeholders) against an
    /// actual response. A placeholder in `recorded` matches any value in
    /// `actual` that fits the pattern; everything else compares for
    /// equality (with float epsilon and optional unordered arrays).
    pub fn matches(&self, recorded: &Value, actual: &Value) -> bool {
        walk_match(self, recorded, actual, "")
    }
}

fn walk_normalize(
    value: &Value,
    path: String,
    parent_key: Option<&str>,
    out: &mut Vec<NormalizationEntry>,
) -> Value {
    match value {
        Value::Object(map) => {
            let mut rewritten = Map::with_capacity(map.len());
            for (k, v) in map {
                let child = format!("{}/{}", path, escape_pointer(k));
                rewritten.insert(k.clone(), walk_normalize(v, child, Some(k), out));
            }
            Value::Object(rewritten)
        }
        Value::Array(items) => {
            let rewritten = items
                .iter()
                .enumerate()
                .map(|(i, v)| walk_normalize(v, format!("{path}/{i}"), None, out))
                .collect();
            Value::Array(rewritten)
        }
        Value::String(s) => normalize_string(s, &path, parent_key, out),
        Value::Number(n) => normalize_number(n, &path, out).unwrap_or_else(|| value.clone()),
        _ => value.clone(),
    }
}

fn normalize_string(
    raw: &str,
    path: &str,
    parent_key: Option<&str>,
    out: &mut Vec<NormalizationEntry>,
) -> Value {
    if let Some(kind) = classify_string(raw, parent_key) {
        out.push(NormalizationEntry {
            path: path.to_string(),
            kind: kind.clone(),
            original: raw.to_string(),
        });
        Value::String(kind.placeholder().to_string())
    } else {
        Value::String(raw.to_string())
    }
}

fn classify_string(raw: &str, parent_key: Option<&str>) -> Option<PlaceholderKind> {
    if let Some(key) = parent_key {
        if is_trace_key(key) && !raw.is_empty() {
            return Some(PlaceholderKind::TraceId);
        }
        if is_etag_key(key) && etag_regex().is_match(raw) {
            return Some(PlaceholderKind::ETag);
        }
    }
    if uuid_regex().is_match(raw) {
        return Some(PlaceholderKind::Uuid);
    }
    if timestamp_regex().is_match(raw) {
        return Some(PlaceholderKind::Timestamp);
    }
    None
}

fn normalize_number(
    n: &serde_json::Number,
    path: &str,
    out: &mut Vec<NormalizationEntry>,
) -> Option<Value> {
    let as_u64 = n.as_u64()?;
    let s = as_u64.to_string();
    if epoch_regex().is_match(&s) {
        out.push(NormalizationEntry {
            path: path.to_string(),
            kind: PlaceholderKind::Epoch,
            original: s,
        });
        Some(Value::String(EPOCH_PLACEHOLDER.to_string()))
    } else {
        None
    }
}

fn walk_match(n: &Normalizer, recorded: &Value, actual: &Value, path: &str) -> bool {
    if let Value::String(s) = recorded {
        if let Some(kind) = placeholder_kind(s) {
            return value_fits_placeholder(&kind, actual);
        }
    }
    match (recorded, actual) {
        (Value::Object(r), Value::Object(a)) => match_objects(n, r, a, path),
        (Value::Array(r), Value::Array(a)) => match_arrays(n, r, a, path),
        (Value::Number(r), Value::Number(a)) => numbers_match(r, a, n.float_epsilon),
        (Value::Null, Value::Null) => true,
        (Value::Bool(r), Value::Bool(a)) => r == a,
        (Value::String(r), Value::String(a)) => r == a,
        _ => false,
    }
}

fn match_objects(
    n: &Normalizer,
    recorded: &Map<String, Value>,
    actual: &Map<String, Value>,
    path: &str,
) -> bool {
    let r: BTreeMap<&String, &Value> = recorded.iter().collect();
    let a: BTreeMap<&String, &Value> = actual.iter().collect();
    if r.len() != a.len() {
        return false;
    }
    for (k, rv) in &r {
        let Some(av) = a.get(k) else {
            return false;
        };
        let child = format!("{}/{}", path, escape_pointer(k));
        if !walk_match(n, rv, av, &child) {
            return false;
        }
    }
    true
}

fn match_arrays(n: &Normalizer, recorded: &[Value], actual: &[Value], path: &str) -> bool {
    if recorded.len() != actual.len() {
        return false;
    }
    if n.unordered_paths.iter().any(|p| p == path) {
        return match_arrays_unordered(n, recorded, actual, path);
    }
    for (i, (rv, av)) in recorded.iter().zip(actual.iter()).enumerate() {
        let child = format!("{path}/{i}");
        if !walk_match(n, rv, av, &child) {
            return false;
        }
    }
    true
}

fn match_arrays_unordered(
    n: &Normalizer,
    recorded: &[Value],
    actual: &[Value],
    path: &str,
) -> bool {
    let mut consumed = vec![false; actual.len()];
    for (i, rv) in recorded.iter().enumerate() {
        let child = format!("{path}/{i}");
        let mut found = false;
        for (j, av) in actual.iter().enumerate() {
            if !consumed[j] && walk_match(n, rv, av, &child) {
                consumed[j] = true;
                found = true;
                break;
            }
        }
        if !found {
            return false;
        }
    }
    true
}

fn numbers_match(r: &serde_json::Number, a: &serde_json::Number, eps: f64) -> bool {
    match (r.as_f64(), a.as_f64()) {
        (Some(rf), Some(af)) => (rf - af).abs() < eps,
        _ => r == a,
    }
}

fn placeholder_kind(s: &str) -> Option<PlaceholderKind> {
    match s {
        TIMESTAMP_PLACEHOLDER => Some(PlaceholderKind::Timestamp),
        UUID_PLACEHOLDER => Some(PlaceholderKind::Uuid),
        EPOCH_PLACEHOLDER => Some(PlaceholderKind::Epoch),
        ETAG_PLACEHOLDER => Some(PlaceholderKind::ETag),
        TRACE_ID_PLACEHOLDER => Some(PlaceholderKind::TraceId),
        _ => None,
    }
}

fn value_fits_placeholder(kind: &PlaceholderKind, actual: &Value) -> bool {
    match (kind, actual) {
        (PlaceholderKind::Timestamp, Value::String(s)) => timestamp_regex().is_match(s),
        (PlaceholderKind::Uuid, Value::String(s)) => uuid_regex().is_match(s),
        (PlaceholderKind::ETag, Value::String(s)) => etag_regex().is_match(s),
        (PlaceholderKind::TraceId, Value::String(s)) => !s.is_empty(),
        (PlaceholderKind::Epoch, Value::Number(n)) => n
            .as_u64()
            .map(|v| epoch_regex().is_match(&v.to_string()))
            .unwrap_or(false),
        (PlaceholderKind::Epoch, Value::String(s)) => epoch_regex().is_match(s),
        (PlaceholderKind::Custom(_), _) => false,
        _ => false,
    }
}

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

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

    #[test]
    fn timestamp_is_replaced_with_placeholder() {
        let n = Normalizer::default();
        let r = n.normalize(&json!({"createdAt": "2026-05-15T12:34:56.789Z"}));
        assert_eq!(r.value["createdAt"], json!(TIMESTAMP_PLACEHOLDER));
        assert_eq!(r.replacements.len(), 1);
        assert_eq!(r.replacements[0].kind, PlaceholderKind::Timestamp);
        assert_eq!(r.replacements[0].path, "/createdAt");
    }

    #[test]
    fn uuid_v4_is_replaced_with_placeholder() {
        let n = Normalizer::default();
        let r = n.normalize(&json!({"id": "550e8400-e29b-41d4-a716-446655440000"}));
        assert_eq!(r.value["id"], json!(UUID_PLACEHOLDER));
        assert_eq!(r.replacements[0].kind, PlaceholderKind::Uuid);
    }

    #[test]
    fn unix_epoch_number_is_replaced_with_placeholder() {
        let n = Normalizer::default();
        let r = n.normalize(&json!({"seconds": 1_715_000_000u64, "millis": 1_715_000_000_000u64}));
        assert_eq!(r.value["seconds"], json!(EPOCH_PLACEHOLDER));
        assert_eq!(r.value["millis"], json!(EPOCH_PLACEHOLDER));
    }

    #[test]
    fn etag_only_replaces_when_key_is_etag() {
        let n = Normalizer::default();
        let r = n.normalize(&json!({"etag": "W/\"abc123\"", "other": "\"abc123\""}));
        assert_eq!(r.value["etag"], json!(ETAG_PLACEHOLDER));
        assert_eq!(r.value["other"], json!("\"abc123\""));
    }

    #[test]
    fn trace_keys_are_replaced_regardless_of_value_shape() {
        let n = Normalizer::default();
        let r = n.normalize(&json!({
            "trace_id": "anything-goes",
            "RequestId": "01HXYZ",
            "correlationId": "xyz",
            "ignored": "plain"
        }));
        assert_eq!(r.value["trace_id"], json!(TRACE_ID_PLACEHOLDER));
        assert_eq!(r.value["RequestId"], json!(TRACE_ID_PLACEHOLDER));
        assert_eq!(r.value["correlationId"], json!(TRACE_ID_PLACEHOLDER));
        assert_eq!(r.value["ignored"], json!("plain"));
    }

    #[test]
    fn placeholder_matches_a_fresh_value_of_the_same_kind() {
        let n = Normalizer::default();
        let recorded = json!({
            "id": UUID_PLACEHOLDER,
            "ts": TIMESTAMP_PLACEHOLDER,
            "etag": ETAG_PLACEHOLDER,
            "trace_id": TRACE_ID_PLACEHOLDER,
            "epoch": EPOCH_PLACEHOLDER,
        });
        let actual = json!({
            "id": "550e8400-e29b-41d4-a716-446655440000",
            "ts": "2026-05-15T12:34:56Z",
            "etag": "\"deadbeef\"",
            "trace_id": "req-xyz",
            "epoch": 1_715_000_000u64,
        });
        assert!(n.matches(&recorded, &actual));
    }

    #[test]
    fn mismatched_placeholder_rejects_wrong_kind() {
        let n = Normalizer::default();
        // UUID placeholder must not accept a non-UUID string.
        assert!(!n.matches(
            &json!({"id": UUID_PLACEHOLDER}),
            &json!({"id": "not-a-uuid"})
        ));
        // Timestamp placeholder must not accept an integer.
        assert!(!n.matches(&json!({"ts": TIMESTAMP_PLACEHOLDER}), &json!({"ts": 1})));
    }

    #[test]
    fn float_epsilon_admits_small_drift() {
        let n = Normalizer::default().with_float_epsilon(0.001);
        assert!(n.matches(&json!({"v": 1.0}), &json!({"v": 1.0005})));
        assert!(!n.matches(&json!({"v": 1.0}), &json!({"v": 1.5})));
    }

    #[test]
    fn map_key_order_is_ignored() {
        let n = Normalizer::default();
        let a = json!({"a": 1, "b": 2, "c": 3});
        let b = json!({"c": 3, "b": 2, "a": 1});
        assert!(n.matches(&a, &b));
    }

    #[test]
    fn arrays_are_ordered_by_default_but_unordered_when_configured() {
        let n = Normalizer::default();
        let recorded = json!({"items": [1, 2, 3]});
        let actual = json!({"items": [3, 2, 1]});
        assert!(!n.matches(&recorded, &actual));

        let n_unordered = Normalizer::default().with_unordered_paths(vec!["/items".into()]);
        assert!(n_unordered.matches(&recorded, &actual));
    }

    #[test]
    fn strict_mode_rejects_auto_normalization() {
        let s = Normalizer::strict();
        // In strict mode, normalize() leaves the value untouched.
        let v = json!({"id": "550e8400-e29b-41d4-a716-446655440000"});
        let r = s.normalize(&v);
        assert_eq!(r.value, v);
        assert!(r.replacements.is_empty());
        // matches() still falls back to literal equality (placeholders only
        // expand if the recorded value already contains them).
        assert!(s.matches(&v, &v));
        assert!(!s.matches(&v, &json!({"id": "00000000-0000-4000-8000-000000000000"})));
    }

    #[test]
    fn deeply_nested_values_are_walked() {
        let n = Normalizer::default();
        let v = json!({
            "results": [
                {"meta": {"trace_id": "abc", "created": "2026-01-01T00:00:00Z"}},
                {"meta": {"trace_id": "def", "created": "2026-02-02T00:00:00Z"}}
            ]
        });
        let r = n.normalize(&v);
        let arr = r.value["results"].as_array().expect("array");
        assert_eq!(arr[0]["meta"]["trace_id"], json!(TRACE_ID_PLACEHOLDER));
        assert_eq!(arr[1]["meta"]["created"], json!(TIMESTAMP_PLACEHOLDER));
        // Four replacements: two trace ids + two timestamps.
        assert_eq!(r.replacements.len(), 4);
    }

    #[test]
    fn null_value_differs_from_missing_key() {
        let n = Normalizer::default();
        let recorded = json!({"a": null});
        let actual_missing = json!({});
        let actual_null = json!({"a": null});
        assert!(!n.matches(&recorded, &actual_missing));
        assert!(n.matches(&recorded, &actual_null));
    }

    #[test]
    fn map_with_different_size_does_not_match() {
        let n = Normalizer::default();
        assert!(!n.matches(&json!({"a": 1}), &json!({"a": 1, "b": 2})));
    }
}
