//! WWW-Authenticate challenge parsing for actionable auth failures.
//!
//! A bare `HTTP 401` tells a headless agent nothing about the fix. The
//! `WWW-Authenticate` header names the scheme the server expects (and, for
//! OAuth protected resources per RFC 9728, where its metadata lives), so a
//! failure message that quotes it turns "opaque 401" into "this server wants
//! a Bearer token, here is the input to set" (WOR-1274). This module is the
//! one place that header is parsed; the transports and the doctor both fold
//! [`AuthChallenge::summary`] into their error payloads.

/// The challenge params worth echoing back to an operator or agent, in the
/// order they should appear. Anything else (nonce, qop, ...) is noise for
/// the "what do I set?" question this summary answers.
const SUMMARY_PARAMS: [&str; 5] = [
    "realm",
    "error",
    "error_description",
    "scope",
    "resource_metadata",
];

/// Cap on the rendered summary so a hostile or chatty server cannot bloat
/// an error message that ends up in an agent's context window.
const SUMMARY_CAP: usize = 160;

/// One parsed `WWW-Authenticate` challenge: the scheme plus its auth params.
///
/// Exists so callers can branch on the scheme (`Bearer` means a token env
/// var fixes it) without re-parsing the header's comma-and-quotes grammar.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuthChallenge {
    /// The advertised scheme, e.g. `Bearer`, `Basic`, `DPoP`.
    pub scheme: String,
    /// `key=value` auth params in header order, quotes stripped.
    pub params: Vec<(String, String)>,
}

impl AuthChallenge {
    /// Parse the first challenge out of a `WWW-Authenticate` header value.
    /// Returns `None` for an empty or token-less value; a scheme with no
    /// params (`WWW-Authenticate: Bearer`) is valid and common.
    pub fn parse(header: &str) -> Option<Self> {
        let trimmed = header.trim();
        let mut split = trimmed.splitn(2, char::is_whitespace);
        let scheme = split.next()?.trim().trim_end_matches(',');
        if scheme.is_empty()
            || !scheme
                .chars()
                .all(|c| c.is_ascii_alphanumeric() || c == '-')
        {
            return None;
        }
        let params = split.next().map(parse_params).unwrap_or_default();
        Some(Self {
            scheme: scheme.to_string(),
            params,
        })
    }

    /// One-line, length-capped rendering for error messages, e.g.
    /// `Bearer (realm="mcp", error="invalid_token")`. Only the params an
    /// operator can act on are included.
    pub fn summary(&self) -> String {
        let mut shown: Vec<String> = Vec::new();
        for key in SUMMARY_PARAMS {
            if let Some((_, v)) = self.params.iter().find(|(k, _)| k == key) {
                shown.push(format!("{key}=\"{v}\""));
            }
        }
        let mut out = if shown.is_empty() {
            self.scheme.clone()
        } else {
            format!("{} ({})", self.scheme, shown.join(", "))
        };
        if out.chars().count() > SUMMARY_CAP {
            out = out.chars().take(SUMMARY_CAP).collect();
            out.push_str("...");
        }
        out
    }
}

/// Parse the `key=value, key="quoted value"` tail of a challenge. Tolerant
/// by design: a malformed param is skipped rather than failing the whole
/// header, because the summary is a diagnostic aid, not a protocol step.
fn parse_params(tail: &str) -> Vec<(String, String)> {
    let mut params = Vec::new();
    for part in split_outside_quotes(tail) {
        let Some((key, value)) = part.split_once('=') else {
            continue;
        };
        let key = key.trim().to_ascii_lowercase();
        let value = value.trim().trim_matches('"').trim();
        if key.is_empty() || value.is_empty() {
            continue;
        }
        params.push((key, sanitize(value)));
    }
    params
}

/// Split on commas that sit outside double quotes, so a quoted
/// `error_description="expired, please renew"` survives intact.
fn split_outside_quotes(s: &str) -> Vec<&str> {
    let mut parts = Vec::new();
    let mut start = 0;
    let mut in_quotes = false;
    for (i, c) in s.char_indices() {
        match c {
            '"' => in_quotes = !in_quotes,
            ',' if !in_quotes => {
                parts.push(&s[start..i]);
                start = i + 1;
            }
            _ => {}
        }
    }
    parts.push(&s[start..]);
    parts
}

/// Strip control characters so a hostile header cannot smuggle newlines or
/// escape sequences into a log line or an agent-facing error message.
fn sanitize(value: &str) -> String {
    value.chars().filter(|c| !c.is_control()).collect()
}

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

    #[test]
    fn parses_bearer_with_realm_and_error() {
        let c = AuthChallenge::parse("Bearer realm=\"mcp\", error=\"invalid_token\"").unwrap();
        assert_eq!(c.scheme, "Bearer");
        assert_eq!(
            c.summary(),
            "Bearer (realm=\"mcp\", error=\"invalid_token\")"
        );
    }

    #[test]
    fn parses_a_bare_scheme_with_no_params() {
        let c = AuthChallenge::parse("Bearer").unwrap();
        assert_eq!(c.scheme, "Bearer");
        assert!(c.params.is_empty());
        assert_eq!(c.summary(), "Bearer");
    }

    #[test]
    fn surfaces_rfc_9728_resource_metadata() {
        let c = AuthChallenge::parse(
            "Bearer resource_metadata=\"https://mcp.example.com/.well-known/oauth-protected-resource\"",
        )
        .unwrap();
        assert!(c.summary().contains("resource_metadata"));
        assert!(c.summary().contains("oauth-protected-resource"));
    }

    #[test]
    fn quoted_commas_do_not_split_a_param() {
        let c = AuthChallenge::parse("Bearer error_description=\"expired, please renew\"").unwrap();
        assert_eq!(
            c.params,
            vec![(
                "error_description".to_string(),
                "expired, please renew".to_string()
            )]
        );
    }

    #[test]
    fn empty_or_garbage_header_yields_none() {
        assert!(AuthChallenge::parse("").is_none());
        assert!(AuthChallenge::parse("   ").is_none());
        assert!(AuthChallenge::parse("=nonsense").is_none());
    }

    #[test]
    fn summary_is_capped_against_a_hostile_header() {
        let big = format!("Bearer realm=\"{}\"", "x".repeat(500));
        let c = AuthChallenge::parse(&big).unwrap();
        assert!(c.summary().chars().count() <= SUMMARY_CAP + 3);
    }

    #[test]
    fn control_characters_are_stripped_from_param_values() {
        let c = AuthChallenge::parse("Bearer realm=\"a\nb\"").unwrap();
        assert_eq!(c.params[0].1, "ab");
    }

    #[test]
    fn unactionable_params_are_parsed_but_left_out_of_the_summary() {
        let c = AuthChallenge::parse("Digest nonce=\"abc123\", realm=\"r\"").unwrap();
        assert_eq!(c.params.len(), 2);
        assert_eq!(c.summary(), "Digest (realm=\"r\")");
    }
}
