//! HTTP transport model: headers, timeouts, basic TLS knobs.
//!
//! The runner reads this struct to build its `reqwest::Client`. The
//! loader fills it from YAML and the CLI fills it from flags. Validation
//! lives here so every entry point catches the same mistakes (Authorization
//! in `headers:`, bogus header names, missing env vars at connect time).

use std::collections::HashMap;
use std::time::Duration;

use serde::{Deserialize, Serialize};

use crate::Error;

/// Default per-request timeout when the YAML omits `server.http.timeout`.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);

/// Default TCP and TLS connect timeout when the YAML omits
/// `server.http.connect_timeout`.
pub const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(5);

/// Default maximum redirects the runner follows on a single request.
pub const DEFAULT_MAX_REDIRECTS: u8 = 5;

/// A custom HTTP header value.
///
/// YAML accepts two shapes: a bare string (literal value, with `${VAR}`
/// already interpolated by `mcptest-config::interpolate`) or an object
/// `{env: NAME}` that reads the value from an environment variable at connect
/// time. The latter keeps secrets out of the YAML and out of shell history.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum HeaderValue {
    /// Literal value, passed through to the wire as-is.
    Literal(String),
    /// Read the value from the named environment variable at connect time.
    Env(EnvHeader),
}

/// Object form of [`HeaderValue::Env`].
///
/// Exists so `#[serde(untagged)]` on [`HeaderValue`] can distinguish a bare
/// string from `{env: NAME}` without ambiguity.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EnvHeader {
    /// Name of the environment variable to read.
    pub env: String,
}

impl HeaderValue {
    /// Construct a literal header value.
    pub fn literal(value: impl Into<String>) -> Self {
        HeaderValue::Literal(value.into())
    }

    /// Construct an env-backed header value.
    pub fn from_env(name: impl Into<String>) -> Self {
        HeaderValue::Env(EnvHeader { env: name.into() })
    }
}

/// Minimum TLS protocol version requested for HTTP transport.
///
/// The runner refuses to negotiate below this floor. `1.2` is the safe
/// default; `1.3` is opt-in for endpoints that require modern crypto.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum TlsMinVersion {
    /// TLS 1.2 minimum (default).
    #[default]
    #[serde(rename = "1.2")]
    V1_2,
    /// TLS 1.3 minimum.
    #[serde(rename = "1.3")]
    V1_3,
}

/// Basic TLS knobs for HTTP transport.
///
/// mTLS (client certificates) is deferred to v1.1; this struct only carries
/// the verify on/off, CA bundle, and minimum-protocol switches. The defaults
/// are the safe configuration: verification on, TLS 1.2 floor, system CA
/// bundle.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TlsConfig {
    /// When true, the runner skips certificate verification. Dangerous; only
    /// use against a private staging endpoint with a self-signed certificate.
    #[serde(default)]
    pub insecure_skip_verify: bool,
    /// Optional path to a PEM-encoded CA bundle.
    #[serde(default)]
    pub ca_bundle_path: Option<String>,
    /// Minimum TLS protocol version.
    #[serde(default)]
    pub min_version: TlsMinVersion,
}

/// HTTP transport options for a URL server.
///
/// Merged from the YAML `server.http` block and CLI flags. `Default::default()`
/// reflects the documented v1 defaults so callers do not have to repeat them.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HttpConfig {
    /// Per-request timeout. Default 30s.
    #[serde(default = "default_timeout", with = "duration_str")]
    pub timeout: Duration,
    /// TCP and TLS connect timeout. Default 5s.
    #[serde(default = "default_connect_timeout", with = "duration_str")]
    pub connect_timeout: Duration,
    /// Maximum number of redirects to follow on a single request. Default 5.
    #[serde(default = "default_max_redirects")]
    pub max_redirects: u8,
    /// When true, the runner sets a `User-Agent` header identifying mcptest.
    /// Default true.
    #[serde(default = "default_user_agent_override")]
    pub user_agent_override: bool,
    /// Basic TLS knobs.
    #[serde(default)]
    pub tls: TlsConfig,
}

impl Default for HttpConfig {
    fn default() -> Self {
        Self {
            timeout: DEFAULT_TIMEOUT,
            connect_timeout: DEFAULT_CONNECT_TIMEOUT,
            max_redirects: DEFAULT_MAX_REDIRECTS,
            user_agent_override: true,
            tls: TlsConfig::default(),
        }
    }
}

fn default_timeout() -> Duration {
    DEFAULT_TIMEOUT
}

fn default_connect_timeout() -> Duration {
    DEFAULT_CONNECT_TIMEOUT
}

fn default_max_redirects() -> u8 {
    DEFAULT_MAX_REDIRECTS
}

fn default_user_agent_override() -> bool {
    true
}

/// Parse a duration string like `30s`, `500ms`, `1m`, `1h`.
///
/// Whole-second-only would be too coarse for HTTP work (a connect_timeout of
/// `500ms` is realistic), so this parser accepts the four common suffixes.
/// A bare integer with no suffix is rejected; we want the unit to be explicit
/// in the YAML so a future reader can tell what the number means without
/// hunting through docs.
pub fn parse_duration(raw: &str) -> Result<Duration, Error> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err(Error::InvalidMatcherInput(
            "duration must not be empty".to_owned(),
        ));
    }
    let (num, unit) = split_unit(trimmed)
        .ok_or_else(|| Error::InvalidMatcherInput(format!("invalid duration: {raw}")))?;
    let n: u64 = num
        .parse()
        .map_err(|_| Error::InvalidMatcherInput(format!("invalid duration number: {raw}")))?;
    let dur = match unit {
        "ms" => Duration::from_millis(n),
        "s" => Duration::from_secs(n),
        "m" => Duration::from_secs(n * 60),
        "h" => Duration::from_secs(n * 3600),
        other => {
            return Err(Error::InvalidMatcherInput(format!(
                "unknown duration unit `{other}`: use ms, s, m, or h"
            )));
        }
    };
    Ok(dur)
}

fn split_unit(raw: &str) -> Option<(&str, &str)> {
    let idx = raw.find(|c: char| !c.is_ascii_digit())?;
    if idx == 0 {
        return None;
    }
    Some((&raw[..idx], &raw[idx..]))
}

/// Serde adapter so `HttpConfig` deserializes duration fields from strings.
mod duration_str {
    use std::time::Duration;

    use serde::{Deserialize, Deserializer, Serializer};

    pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
        // Reasonable canonical: prefer "Xms" if sub-second, else "Xs".
        if d.as_secs() == 0 && d.subsec_millis() > 0 {
            s.serialize_str(&format!("{}ms", d.as_millis()))
        } else {
            s.serialize_str(&format!("{}s", d.as_secs()))
        }
    }

    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
        let raw = String::deserialize(d)?;
        super::parse_duration(&raw).map_err(serde::de::Error::custom)
    }
}

/// Walks header sources lowest-precedence first and merges them.
///
/// The current order (see ): CLI flags overwrite YAML `server.headers`,
/// which overwrite YAML `imports`. Each level wins by overwriting; there is no
/// list-append behavior, since duplicate header values are almost always a
/// configuration mistake.
pub struct HeaderMerger;

impl HeaderMerger {
    /// Merge `sources` lowest-precedence first. Later sources overwrite
    /// earlier sources on a per-header-name basis (case-insensitive). The
    /// canonical name preserved in the output is the one from the
    /// highest-precedence source.
    pub fn merge(sources: &[HashMap<String, HeaderValue>]) -> HashMap<String, HeaderValue> {
        let mut by_lower: HashMap<String, (String, HeaderValue)> = HashMap::new();
        for source in sources {
            for (name, value) in source {
                by_lower.insert(name.to_ascii_lowercase(), (name.clone(), value.clone()));
            }
        }
        by_lower.into_values().collect()
    }
}

/// Header-name validator.
///
/// Rejects names that should live under `auth:` (Authorization,
/// Proxy-Authorization) so credentials are not silently overwritten by a
/// custom header. Also rejects names that violate RFC 7230 token grammar.
pub struct HeaderValidator;

impl HeaderValidator {
    /// Validate every entry in `headers`.
    ///
    /// Returns `Err` on the first bad entry with a message that points the
    /// caller at the right block (auth vs. headers vs. fix the name).
    pub fn validate(headers: &HashMap<String, HeaderValue>) -> Result<(), Error> {
        for name in headers.keys() {
            check_name(name)?;
        }
        Ok(())
    }
}

fn check_name(name: &str) -> Result<(), Error> {
    if name.is_empty() {
        return Err(Error::InvalidMatcherInput(
            "header name must not be empty".to_owned(),
        ));
    }
    let lower = name.to_ascii_lowercase();
    if lower == "authorization" || lower == "proxy-authorization" {
        return Err(Error::InvalidMatcherInput(format!(
            "header `{name}` is reserved for the `auth:` block, not `headers:`"
        )));
    }
    for ch in name.chars() {
        if !is_token_char(ch) {
            return Err(Error::InvalidMatcherInput(format!(
                "header name `{name}` contains invalid character `{ch}` (RFC 7230 token chars only)"
            )));
        }
    }
    Ok(())
}

/// RFC 7230 token character: ASCII letters and digits plus a small set of
/// punctuation. Anything else (spaces, colons, control chars, non-ASCII) is
/// rejected.
fn is_token_char(c: char) -> bool {
    if !c.is_ascii() {
        return false;
    }
    if c.is_ascii_alphanumeric() {
        return true;
    }
    matches!(
        c,
        '!' | '#' | '$' | '%' | '&' | '\'' | '*' | '+' | '-' | '.' | '^' | '_' | '`' | '|' | '~'
    )
}

/// Resolve every header into a literal string.
///
/// Literal values pass through. `Env(NAME)` values look up `NAME` via the
/// caller-supplied closure and fail when the variable is unset. The closure
/// abstraction keeps the function testable without poking at the process
/// environment.
pub fn resolve(
    headers: &HashMap<String, HeaderValue>,
    env: &dyn Fn(&str) -> Option<String>,
) -> Result<HashMap<String, String>, Error> {
    let mut out = HashMap::with_capacity(headers.len());
    for (name, value) in headers {
        let resolved = match value {
            HeaderValue::Literal(s) => s.clone(),
            HeaderValue::Env(EnvHeader { env: var }) => env(var).ok_or_else(|| {
                Error::InvalidMatcherInput(format!(
                    "environment variable `{var}` referenced by header `{name}` is not set"
                ))
            })?,
        };
        out.insert(name.clone(), resolved);
    }
    Ok(out)
}

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

    #[test]
    fn parses_duration_with_each_unit() {
        assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
        assert_eq!(parse_duration("30s").unwrap(), Duration::from_secs(30));
        assert_eq!(parse_duration("1m").unwrap(), Duration::from_secs(60));
        assert_eq!(parse_duration("2h").unwrap(), Duration::from_secs(7200));
    }

    #[test]
    fn rejects_duration_without_unit() {
        assert!(parse_duration("30").is_err());
    }

    #[test]
    fn rejects_duration_with_unknown_unit() {
        assert!(parse_duration("30d").is_err());
    }

    #[test]
    fn default_http_config_matches_documented_defaults() {
        let c = HttpConfig::default();
        assert_eq!(c.timeout, Duration::from_secs(30));
        assert_eq!(c.connect_timeout, Duration::from_secs(5));
        assert_eq!(c.max_redirects, 5);
        assert!(c.user_agent_override);
        assert!(!c.tls.insecure_skip_verify);
        assert!(c.tls.ca_bundle_path.is_none());
        assert_eq!(c.tls.min_version, TlsMinVersion::V1_2);
    }

    #[test]
    fn deserializes_http_config_from_yaml() {
        let raw = "
timeout: 10s
connect_timeout: 500ms
max_redirects: 0
user_agent_override: false
tls:
  insecure_skip_verify: true
  min_version: \"1.3\"
";
        let c: HttpConfig = serde_yaml::from_str(raw).expect("parse");
        assert_eq!(c.timeout, Duration::from_secs(10));
        assert_eq!(c.connect_timeout, Duration::from_millis(500));
        assert_eq!(c.max_redirects, 0);
        assert!(!c.user_agent_override);
        assert!(c.tls.insecure_skip_verify);
        assert_eq!(c.tls.min_version, TlsMinVersion::V1_3);
    }

    #[test]
    fn deserializes_header_value_literal_and_env_forms() {
        let literal: HeaderValue = serde_yaml::from_str("\"acme\"").expect("parse");
        assert_eq!(literal, HeaderValue::Literal("acme".to_owned()));

        let env: HeaderValue = serde_yaml::from_str("env: MY_TOKEN").expect("parse");
        assert_eq!(env, HeaderValue::from_env("MY_TOKEN"));
    }

    #[test]
    fn merger_lets_later_sources_overwrite_earlier_ones() {
        let mut yaml = HashMap::new();
        yaml.insert("X-Tenant".to_owned(), HeaderValue::literal("yaml"));
        yaml.insert("X-Trace".to_owned(), HeaderValue::literal("yaml"));
        let mut cli = HashMap::new();
        cli.insert("X-Tenant".to_owned(), HeaderValue::literal("cli"));
        let merged = HeaderMerger::merge(&[yaml, cli]);
        assert_eq!(
            merged.get("X-Tenant"),
            Some(&HeaderValue::literal("cli")),
            "CLI source must win"
        );
        assert_eq!(
            merged.get("X-Trace"),
            Some(&HeaderValue::literal("yaml")),
            "unique entries survive"
        );
    }

    #[test]
    fn merger_normalizes_case_so_collisions_are_detected() {
        let mut a = HashMap::new();
        a.insert("x-tenant".to_owned(), HeaderValue::literal("low"));
        let mut b = HashMap::new();
        b.insert("X-Tenant".to_owned(), HeaderValue::literal("high"));
        let merged = HeaderMerger::merge(&[a, b]);
        assert_eq!(merged.len(), 1);
        // The canonical name is the highest-precedence one.
        assert_eq!(merged.get("X-Tenant"), Some(&HeaderValue::literal("high")));
    }

    #[test]
    fn validator_accepts_well_formed_names() {
        let mut h = HashMap::new();
        h.insert("X-Tenant".to_owned(), HeaderValue::literal("acme"));
        h.insert("X-Trace-Id".to_owned(), HeaderValue::literal("123"));
        h.insert("X_API_KEY".to_owned(), HeaderValue::literal("k"));
        HeaderValidator::validate(&h).expect("happy path");
    }

    #[test]
    fn validator_rejects_authorization_regardless_of_case() {
        for raw in ["Authorization", "authorization", "AUTHORIZATION"] {
            let mut h = HashMap::new();
            h.insert(raw.to_owned(), HeaderValue::literal("Bearer x"));
            let err = HeaderValidator::validate(&h).expect_err("must reject");
            assert!(err.to_string().contains("auth:"));
        }
    }

    #[test]
    fn validator_rejects_proxy_authorization() {
        let mut h = HashMap::new();
        h.insert(
            "Proxy-Authorization".to_owned(),
            HeaderValue::literal("Bearer x"),
        );
        let err = HeaderValidator::validate(&h).expect_err("must reject");
        assert!(err.to_string().contains("auth:"));
    }

    #[test]
    fn validator_rejects_empty_header_name() {
        let mut h = HashMap::new();
        h.insert(String::new(), HeaderValue::literal("x"));
        let err = HeaderValidator::validate(&h).expect_err("must reject");
        assert!(err.to_string().contains("must not be empty"));
    }

    #[test]
    fn validator_rejects_invalid_header_name_chars() {
        for bad in ["X Header With Space", "X:Header", "X-Header\n", "Naïve"] {
            let mut h = HashMap::new();
            h.insert(bad.to_owned(), HeaderValue::literal("x"));
            let err = HeaderValidator::validate(&h).expect_err("must reject");
            assert!(
                err.to_string().contains("invalid character")
                    || err.to_string().contains("token chars"),
                "got: {err}"
            );
        }
    }

    #[test]
    fn resolve_passes_literals_through() {
        let mut h = HashMap::new();
        h.insert("X-Tenant".to_owned(), HeaderValue::literal("acme"));
        let resolved = resolve(&h, &|_| None).expect("resolve");
        assert_eq!(resolved.get("X-Tenant").map(String::as_str), Some("acme"));
    }

    #[test]
    fn resolve_reads_env_via_closure() {
        let mut h = HashMap::new();
        h.insert("X-API".to_owned(), HeaderValue::from_env("MY_TOKEN"));
        let resolved = resolve(&h, &|name| {
            if name == "MY_TOKEN" {
                Some("secret".to_owned())
            } else {
                None
            }
        })
        .expect("resolve");
        assert_eq!(resolved.get("X-API").map(String::as_str), Some("secret"));
    }

    #[test]
    fn resolve_errors_when_env_var_is_missing() {
        let mut h = HashMap::new();
        h.insert("X-API".to_owned(), HeaderValue::from_env("MISSING"));
        let err = resolve(&h, &|_| None).expect_err("must error");
        let msg = err.to_string();
        assert!(msg.contains("MISSING"), "got: {msg}");
        assert!(msg.contains("X-API"), "got: {msg}");
    }
}
