//! `is-xml` matcher: the string value must parse as well-formed XML.
//!
//! Why this exists: MCP tools that wrap legacy systems often hand back an XML
//! document inside a text content block. Asserting the block is well-formed XML
//! (and optionally that its root element has an expected name) is a common,
//! deterministic check that does not belong in a brittle regex.
//!
//! Well-formedness is the core: the value is read with `quick-xml`'s streaming
//! `Reader` until EOF, and any parse error fails the assertion. A malformed
//! argument (a non-object, or an object without the one supported key) is an
//! evaluation error, never a panic.

use quick_xml::events::Event;
use quick_xml::Reader;
use serde_json::Value;

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

/// Evaluate `is-xml`.
///
/// `argument` is either null/absent (just check well-formedness) or an object
/// with a `root` key naming the expected root element. The actual value must be
/// a string; it is parsed event by event until EOF. The first parse error
/// becomes the failure diff so the user sees why the document was rejected.
pub fn evaluate(argument: &Value, actual: &Value) -> Result<AssertionResult> {
    let expected_root = expected_root(argument)?;
    let text = actual.as_str().ok_or_else(|| {
        Error::InvalidMatcherInput("is-xml requires a string-typed actual value".into())
    })?;
    match parse_root(text) {
        Err(note) => Ok(AssertionResult::failed(
            MatcherKind::IsXml,
            diff(actual, note),
        )),
        Ok(root) => match expected_root {
            None => Ok(AssertionResult::passed(MatcherKind::IsXml)),
            Some(want) if root.as_deref() == Some(want) => {
                Ok(AssertionResult::passed(MatcherKind::IsXml))
            }
            Some(want) => Ok(AssertionResult::failed(
                MatcherKind::IsXml,
                diff(
                    actual,
                    format!(
                        "expected root element `{want}`, found {}",
                        root.map(|r| format!("`{r}`"))
                            .unwrap_or_else(|| "no element".into())
                    ),
                ),
            )),
        },
    }
}

/// Pull the optional expected root element name out of the argument. Accepts
/// null/absent (no structural assertion) or `{ root: "name" }`; any other shape
/// is an error so the user fixes the test rather than seeing a silent pass.
fn expected_root(argument: &Value) -> Result<Option<&str>> {
    match argument {
        Value::Null => Ok(None),
        Value::Object(map) => match map.get("root") {
            Some(Value::String(name)) => Ok(Some(name.as_str())),
            Some(_) => Err(Error::InvalidMatcherInput(
                "is-xml `root` must be a string element name".into(),
            )),
            None => Err(Error::InvalidMatcherInput(
                "is-xml object argument must carry a `root` key".into(),
            )),
        },
        _ => Err(Error::InvalidMatcherInput(
            "is-xml argument must be null or an object with a `root` key".into(),
        )),
    }
}

/// Read the document to EOF, returning the first element name encountered.
/// Returns `Err(note)` on any XML parse error so the caller renders it as a
/// failing assertion rather than a structural matcher error.
fn parse_root(text: &str) -> std::result::Result<Option<String>, String> {
    let mut reader = Reader::from_str(text);
    let mut root: Option<String> = None;
    loop {
        match reader.read_event() {
            Ok(Event::Eof) => return Ok(root),
            Ok(Event::Start(e)) | Ok(Event::Empty(e)) => {
                if root.is_none() {
                    root = Some(String::from_utf8_lossy(e.name().as_ref()).into_owned());
                }
            }
            Ok(_) => {}
            Err(e) => return Err(format!("value is not well-formed XML: {e}")),
        }
    }
}

/// Build the single-diff failure shape shared by every `is-xml` failure path.
fn diff(actual: &Value, note: impl Into<String>) -> AssertionDiff {
    AssertionDiff {
        path: String::new(),
        expected: None,
        actual: Some(actual.clone()),
        note: Some(note.into()),
    }
}

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

    #[test]
    fn is_xml_passes_on_well_formed_document() {
        let r = evaluate(&Value::Null, &json!("<note><to>a</to></note>")).unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
        assert_eq!(r.kind, MatcherKind::IsXml);
    }

    #[test]
    fn is_xml_passes_on_self_closing_root() {
        let r = evaluate(&Value::Null, &json!("<empty/>")).unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
    }

    #[test]
    fn is_xml_fails_on_unclosed_tag() {
        let r = evaluate(&Value::Null, &json!("<note><to>a</note>")).unwrap();
        assert!(!r.pass);
        assert!(r.diffs[0]
            .note
            .as_deref()
            .unwrap()
            .contains("not well-formed XML"));
    }

    #[test]
    fn is_xml_fails_on_garbage_input() {
        let r = evaluate(&Value::Null, &json!("<<<not xml")).unwrap();
        assert!(!r.pass);
    }

    #[test]
    fn is_xml_rejects_non_string_target() {
        let err = evaluate(&Value::Null, &json!(42)).unwrap_err();
        assert!(matches!(err, Error::InvalidMatcherInput(_)));
    }

    #[test]
    fn is_xml_checks_root_element_name() {
        let arg = json!({"root": "note"});
        let r = evaluate(&arg, &json!("<note><to>a</to></note>")).unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
    }

    #[test]
    fn is_xml_fails_when_root_element_differs() {
        let arg = json!({"root": "memo"});
        let r = evaluate(&arg, &json!("<note/>")).unwrap();
        assert!(!r.pass);
        assert!(r.diffs[0]
            .note
            .as_deref()
            .unwrap()
            .contains("expected root element `memo`"));
    }

    #[test]
    fn is_xml_object_argument_without_root_key_errors() {
        let err = evaluate(&json!({"nope": 1}), &json!("<a/>")).unwrap_err();
        assert!(matches!(err, Error::InvalidMatcherInput(_)));
    }

    #[test]
    fn is_xml_root_must_be_a_string() {
        let err = evaluate(&json!({"root": 5}), &json!("<a/>")).unwrap_err();
        assert!(matches!(err, Error::InvalidMatcherInput(_)));
    }
}
