//! Response header matchers.
//!
//! Why this module exists: HTTP MCP servers often signal protocol details
//! through headers (rate-limit info, cache state, content negotiation). Tests
//! need to assert "this header exists and looks right" and "this header is
//! absent" without writing transport plumbing. Stdio transport has no
//! headers, so the matcher short-circuits to a skip rather than failing every
//! stdio test that opts into this block.

use std::collections::HashMap;

use serde_json::Value;

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

/// Transport the response came from.
///
/// The matcher inspects this to decide whether headers exist at all. We do
/// not lift this into a more general "context" struct yet because no other
/// matcher needs transport awareness.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Transport {
    /// Subprocess MCP server speaking over stdio. No headers.
    Stdio,
    /// HTTP or SSE MCP server. Headers available.
    Http,
}

/// Variant of header match supported in the `response_headers:` map.
///
/// The variants mirror the YAML surface: bare strings are literal equality,
/// while objects carry one of `regex:`, `schema:`, `exists:`, or `contains:`.
/// `PartialEq` is derived because the typed suite carries parsed header
/// expectations and the suite model is comparable in tests (WOR-1280).
#[derive(Debug, Clone, PartialEq)]
pub enum HeaderMatch {
    /// Exact (case-insensitive value) equality.
    Literal(String),
    /// Regex pattern applied to the header value.
    Regex(String),
    /// JSON Schema validated against the header value rendered as a string.
    Schema(Value),
    /// Header must be present; value is unchecked.
    Exists,
    /// Header value must contain this substring (case-sensitive).
    Contains(String),
}

/// Evaluate `response_headers:` and `response_headers_absent:` together.
///
/// Header names are matched case-insensitively per RFC 7230. Failure paths
/// canonicalize names to Title-Case (`Content-Type`) so the reporter does not
/// mirror whatever case the user typed in YAML. For stdio transports the
/// matcher short-circuits to a pass with a skip note.
pub fn evaluate_response_headers(
    transport: Transport,
    expected: &HashMap<String, HeaderMatch>,
    absent: &[String],
    actual_headers: &HashMap<String, String>,
) -> Result<AssertionResult> {
    if matches!(transport, Transport::Stdio) {
        return Ok(AssertionResult::passed_with_note(
            MatcherKind::ResponseHeaders,
            "skipped: stdio target has no response headers",
        ));
    }
    let lowered = lowercase_index(actual_headers);
    let mut diffs: Vec<AssertionDiff> = Vec::new();

    for (name, expectation) in expected {
        let canonical = title_case_header(name);
        match lowered.get(&name.to_ascii_lowercase()) {
            None => diffs.push(AssertionDiff {
                path: format!("/headers/{canonical}"),
                expected: Some(describe_match(expectation)),
                actual: None,
                note: Some(format!("missing header `{canonical}`")),
            }),
            Some(actual_value) => {
                if let Some(d) = check_match(&canonical, expectation, actual_value)? {
                    diffs.push(d);
                }
            }
        }
    }
    for name in absent {
        if lowered.contains_key(&name.to_ascii_lowercase()) {
            let canonical = title_case_header(name);
            diffs.push(AssertionDiff {
                path: format!("/headers/{canonical}"),
                expected: None,
                actual: Some(Value::String(
                    lowered
                        .get(&name.to_ascii_lowercase())
                        .cloned()
                        .unwrap_or_default(),
                )),
                note: Some(format!("header `{canonical}` should be absent")),
            });
        }
    }

    if diffs.is_empty() {
        Ok(AssertionResult::passed(MatcherKind::ResponseHeaders))
    } else {
        Ok(AssertionResult {
            kind: MatcherKind::ResponseHeaders,
            pass: false,
            diffs,
            captures: Default::default(),
        })
    }
}

/// Compare one header value against its expectation.
///
/// Returns `Ok(None)` on a match, `Ok(Some(diff))` on a structured mismatch,
/// and `Err(...)` only when the expectation itself is malformed (bad regex,
/// bad schema), which is a programmer error not a test failure.
fn check_match(
    canonical: &str,
    expectation: &HeaderMatch,
    actual: &str,
) -> Result<Option<AssertionDiff>> {
    match expectation {
        HeaderMatch::Literal(want) => {
            if want.eq_ignore_ascii_case(actual) {
                Ok(None)
            } else {
                Ok(Some(AssertionDiff {
                    path: format!("/headers/{canonical}"),
                    expected: Some(Value::String(want.clone())),
                    actual: Some(Value::String(actual.to_string())),
                    note: Some(format!("header `{canonical}` value mismatch")),
                }))
            }
        }
        HeaderMatch::Regex(pattern) => {
            let re = regex::Regex::new(pattern)?;
            if re.is_match(actual) {
                Ok(None)
            } else {
                Ok(Some(AssertionDiff {
                    path: format!("/headers/{canonical}"),
                    expected: Some(Value::String(pattern.clone())),
                    actual: Some(Value::String(actual.to_string())),
                    note: Some(format!(
                        "header `{canonical}` did not match regex `{pattern}`"
                    )),
                }))
            }
        }
        HeaderMatch::Schema(schema) => {
            let validator = jsonschema::validator_for(schema)
                .map_err(|e| Error::InvalidSchema(format!("{e}")))?;
            let value = Value::String(actual.to_string());
            if validator.is_valid(&value) {
                Ok(None)
            } else {
                let note = validator
                    .iter_errors(&value)
                    .map(|e| e.to_string())
                    .next()
                    .unwrap_or_else(|| "schema validation failed".into());
                Ok(Some(AssertionDiff {
                    path: format!("/headers/{canonical}"),
                    expected: Some(schema.clone()),
                    actual: Some(value),
                    note: Some(note),
                }))
            }
        }
        HeaderMatch::Exists => Ok(None),
        HeaderMatch::Contains(needle) => {
            if actual.contains(needle.as_str()) {
                Ok(None)
            } else {
                Ok(Some(AssertionDiff {
                    path: format!("/headers/{canonical}"),
                    expected: Some(Value::String(needle.clone())),
                    actual: Some(Value::String(actual.to_string())),
                    note: Some(format!("header `{canonical}` does not contain `{needle}`")),
                }))
            }
        }
    }
}

/// Build a lowercase index of the actual headers so lookups are O(1)
/// regardless of how the transport spelled them.
fn lowercase_index(actual: &HashMap<String, String>) -> HashMap<String, String> {
    actual
        .iter()
        .map(|(k, v)| (k.to_ascii_lowercase(), v.clone()))
        .collect()
}

/// Render a header name as Title-Case (`content-type` -> `Content-Type`) for
/// failure messages. Pure RFC 7230 cosmetic; the wire is case-insensitive.
fn title_case_header(name: &str) -> String {
    let mut out = String::with_capacity(name.len());
    let mut at_start = true;
    for ch in name.chars() {
        if ch == '-' {
            out.push('-');
            at_start = true;
            continue;
        }
        if at_start {
            for u in ch.to_uppercase() {
                out.push(u);
            }
            at_start = false;
        } else {
            for l in ch.to_lowercase() {
                out.push(l);
            }
        }
    }
    out
}

/// Render a header expectation as JSON for inclusion in the diff. Keeps the
/// failure shape predictable for the JSON reporter.
fn describe_match(m: &HeaderMatch) -> Value {
    match m {
        HeaderMatch::Literal(s) => Value::String(s.clone()),
        HeaderMatch::Regex(p) => serde_json::json!({"regex": p}),
        HeaderMatch::Schema(s) => serde_json::json!({"schema": s}),
        HeaderMatch::Exists => serde_json::json!({"exists": true}),
        HeaderMatch::Contains(s) => serde_json::json!({"contains": s}),
    }
}

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

    fn headers(pairs: &[(&str, &str)]) -> HashMap<String, String> {
        pairs
            .iter()
            .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
            .collect()
    }

    fn expected(pairs: &[(&str, HeaderMatch)]) -> HashMap<String, HeaderMatch> {
        pairs
            .iter()
            .map(|(k, v)| ((*k).to_string(), v.clone()))
            .collect()
    }

    #[test]
    fn literal_match_is_case_insensitive_on_name() {
        let actual = headers(&[("Content-Type", "application/json")]);
        let exp = expected(&[(
            "content-type",
            HeaderMatch::Literal("application/json".into()),
        )]);
        let r = evaluate_response_headers(Transport::Http, &exp, &[], &actual).unwrap();
        assert!(r.pass, "diffs: {:?}", r.diffs);
    }

    #[test]
    fn missing_header_fails_with_title_case_name_in_diff() {
        let actual = headers(&[("Foo", "bar")]);
        let exp = expected(&[("x-request-id", HeaderMatch::Literal("abc".into()))]);
        let r = evaluate_response_headers(Transport::Http, &exp, &[], &actual).unwrap();
        assert!(!r.pass);
        assert_eq!(r.diffs[0].path, "/headers/X-Request-Id");
        assert!(r.diffs[0]
            .note
            .as_deref()
            .unwrap()
            .contains("missing header"));
    }

    #[test]
    fn regex_variant_matches_header_value() {
        let actual = headers(&[("X-Trace-Id", "trace-12345")]);
        let exp = expected(&[("x-trace-id", HeaderMatch::Regex(r"^trace-\d+$".into()))]);
        let r = evaluate_response_headers(Transport::Http, &exp, &[], &actual).unwrap();
        assert!(r.pass);
    }

    #[test]
    fn schema_variant_validates_value_string() {
        let actual = headers(&[("X-Count", "42")]);
        let schema = json!({"type": "string", "pattern": "^\\d+$"});
        let exp = expected(&[("x-count", HeaderMatch::Schema(schema))]);
        let r = evaluate_response_headers(Transport::Http, &exp, &[], &actual).unwrap();
        assert!(r.pass);
    }

    #[test]
    fn exists_variant_passes_with_any_value() {
        let actual = headers(&[("X-Custom", "")]);
        let exp = expected(&[("x-custom", HeaderMatch::Exists)]);
        let r = evaluate_response_headers(Transport::Http, &exp, &[], &actual).unwrap();
        assert!(r.pass);
    }

    #[test]
    fn contains_variant_finds_substring() {
        let actual = headers(&[("Server", "nginx/1.25.3 (ubuntu)")]);
        let exp = expected(&[("server", HeaderMatch::Contains("nginx".into()))]);
        let r = evaluate_response_headers(Transport::Http, &exp, &[], &actual).unwrap();
        assert!(r.pass);
    }

    #[test]
    fn absent_list_flags_present_header() {
        let actual = headers(&[("Set-Cookie", "session=abc")]);
        let r = evaluate_response_headers(
            Transport::Http,
            &HashMap::new(),
            &["set-cookie".into()],
            &actual,
        )
        .unwrap();
        assert!(!r.pass);
        assert_eq!(r.diffs[0].path, "/headers/Set-Cookie");
        assert!(r.diffs[0]
            .note
            .as_deref()
            .unwrap()
            .contains("should be absent"));
    }

    #[test]
    fn stdio_transport_skips_with_note() {
        let exp = expected(&[(
            "content-type",
            HeaderMatch::Literal("application/json".into()),
        )]);
        let r = evaluate_response_headers(
            Transport::Stdio,
            &exp,
            &["set-cookie".into()],
            &HashMap::new(),
        )
        .unwrap();
        assert!(r.pass);
        assert_eq!(
            r.diffs[0].note.as_deref(),
            Some("skipped: stdio target has no response headers")
        );
    }

    #[test]
    fn malformed_regex_is_structural_error() {
        let actual = headers(&[("X-Trace", "abc")]);
        let exp = expected(&[("x-trace", HeaderMatch::Regex("(".into()))]);
        let err = evaluate_response_headers(Transport::Http, &exp, &[], &actual).unwrap_err();
        assert!(matches!(err, Error::RegexCompile(_)));
    }

    #[test]
    fn literal_mismatch_reports_actual_value() {
        let actual = headers(&[("X-Env", "prod")]);
        let exp = expected(&[("x-env", HeaderMatch::Literal("staging".into()))]);
        let r = evaluate_response_headers(Transport::Http, &exp, &[], &actual).unwrap();
        assert!(!r.pass);
        assert_eq!(r.diffs[0].actual.as_ref().unwrap(), &json!("prod"));
        assert_eq!(r.diffs[0].expected.as_ref().unwrap(), &json!("staging"));
    }
}
