//! `contains` matcher: string substring, object subset, and array
//! multiset-superset.
//!
//! Why this matcher exists: most MCP responses include extra detail the test
//! does not care about (surrounding prose, extra fields, server-generated IDs).
//! `contains` lets the user say "must include this" without spelling out the
//! whole value. The three shapes:
//!
//! - Strings: case-sensitive substring (`"in Sacramento."` contains
//!   `"Sacramento"`). Use `icontains` for the case-insensitive form and
//!   `exact` for full equality.
//! - Objects: subset, every listed key must be present and itself contained.
//! - Arrays: multiset superset, so `[1, 1, 2]` is *not* contained in
//!   `[1, 2, 3]`.

use serde_json::Value;

use super::{AssertionDiff, AssertionResult, MatcherKind};
use crate::Result;

/// Evaluate the `contains` matcher.
///
/// Strings: `expected` must be a case-sensitive substring of `actual`.
/// Objects: every key in `expected` must appear in `actual` with a value that
/// itself satisfies `contains` recursively. Arrays: every element in
/// `expected` must have a matching, unconsumed element in `actual`
/// (multiset semantics). Other scalars fall back to deep equality.
pub fn evaluate(expected: &Value, actual: &Value) -> Result<AssertionResult> {
    match contains_diff(expected, actual, String::new()) {
        None => Ok(AssertionResult::passed(MatcherKind::Contains)),
        Some(d) => Ok(AssertionResult::failed(MatcherKind::Contains, d)),
    }
}

fn contains_diff(expected: &Value, actual: &Value, path: String) -> Option<AssertionDiff> {
    match (expected, actual) {
        (Value::Object(e), Value::Object(a)) => diff_objects(e, a, path),
        (Value::Array(e), Value::Array(a)) => diff_arrays(e, a, path),
        // Strings are case-sensitive substring. `icontains` is the
        // case-insensitive form; `exact` is full equality.
        (Value::String(e), Value::String(a)) => {
            if a.contains(e.as_str()) {
                None
            } else {
                Some(AssertionDiff {
                    path,
                    expected: Some(Value::String(e.clone())),
                    actual: Some(Value::String(a.clone())),
                    note: Some(format!("substring `{e}` not found in actual string")),
                })
            }
        }
        (e, a) if e == a => None,
        (e, a) => Some(AssertionDiff {
            path,
            expected: Some(e.clone()),
            actual: Some(a.clone()),
            note: Some("scalar values are not equal".into()),
        }),
    }
}

fn diff_objects(
    expected: &serde_json::Map<String, Value>,
    actual: &serde_json::Map<String, Value>,
    path: String,
) -> Option<AssertionDiff> {
    let mut keys: Vec<&String> = expected.keys().collect();
    keys.sort();
    for key in keys {
        let child = format!("{}/{}", path, escape_pointer(key));
        match actual.get(key) {
            None => {
                return Some(AssertionDiff {
                    path: child,
                    expected: Some(expected[key].clone()),
                    actual: None,
                    note: Some(format!("missing key `{key}` in actual value")),
                });
            }
            Some(a) => {
                if let Some(d) = contains_diff(&expected[key], a, child) {
                    return Some(d);
                }
            }
        }
    }
    None
}

fn diff_arrays(expected: &[Value], actual: &[Value], path: String) -> Option<AssertionDiff> {
    // Multiset semantics: consume each match once. Greedy is fine because
    // contains uses strict deep equality on items; no overlap ambiguity.
    let mut consumed = vec![false; actual.len()];
    for (i, item) in expected.iter().enumerate() {
        let mut found = false;
        for (j, candidate) in actual.iter().enumerate() {
            if !consumed[j] && candidate == item {
                consumed[j] = true;
                found = true;
                break;
            }
        }
        if !found {
            return Some(AssertionDiff {
                path: format!("{path}/{i}"),
                expected: Some(item.clone()),
                actual: None,
                note: Some(format!(
                    "expected element at index {i} not found in actual array (multiset)"
                )),
            });
        }
    }
    None
}

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

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

    #[test]
    fn object_subset_passes_with_extra_actual_keys() {
        let expected = json!({"name": "alice", "role": "admin"});
        let actual = json!({"name": "alice", "role": "admin", "id": 7, "ts": "now"});
        let r = evaluate(&expected, &actual).unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
    }

    #[test]
    fn missing_key_in_actual_is_a_failure_with_path() {
        let r = evaluate(&json!({"a": 1, "b": 2}), &json!({"a": 1})).unwrap();
        assert!(!r.pass);
        assert_eq!(r.diffs[0].path, "/b");
        assert!(r.diffs[0].note.as_deref().unwrap().contains("missing key"));
    }

    #[test]
    fn array_multiset_requires_enough_duplicates() {
        // [1,1,2] must NOT be contained in [1,2,3] because there is only one 1.
        let r = evaluate(&json!([1, 1, 2]), &json!([1, 2, 3])).unwrap();
        assert!(!r.pass);
        assert!(r.diffs[0].note.as_deref().unwrap().contains("not found"));
    }

    #[test]
    fn array_multiset_passes_when_duplicates_available() {
        let r = evaluate(&json!([1, 1, 2]), &json!([1, 1, 2, 3])).unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
    }

    #[test]
    fn array_is_order_insensitive() {
        let r = evaluate(&json!([1, 2, 3]), &json!([3, 2, 1])).unwrap();
        assert!(r.pass);
    }

    #[test]
    fn string_is_case_sensitive_substring() {
        let r = evaluate(&json!("Sacramento"), &json!("It is rainy in Sacramento.")).unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
        // Case matters: use icontains for case-insensitive.
        let r2 = evaluate(&json!("sacramento"), &json!("rainy in Sacramento")).unwrap();
        assert!(!r2.pass);
        let r3 = evaluate(&json!("hello"), &json!("world")).unwrap();
        assert!(!r3.pass);
        assert!(r3.diffs[0].note.as_deref().unwrap().contains("substring"));
    }

    #[test]
    fn non_string_scalar_falls_back_to_equality() {
        // Edge case: empty objects always contain.
        let r = evaluate(&json!({}), &json!({"anything": 1})).unwrap();
        assert!(r.pass);
        // Numbers compare by equality, not substring.
        assert!(evaluate(&json!(72), &json!(72)).unwrap().pass);
        assert!(!evaluate(&json!(7), &json!(72)).unwrap().pass);
    }
}
