//! HTTP / HTTPS proxy configuration shared across every reqwest
//! client mcptest builds.
//!
//! Why this module exists: corporate networks tunnel every outbound
//! connection through an HTTP CONNECT proxy. Every transport
//! (`StreamableHttp`, `LegacySse`) and every LLM provider client
//! needs the same proxy settings, and the user expects to set them
//! once at the CLI. Collecting the policy here keeps the builder
//! call sites short (`apply(builder)`) and the parsing rules
//! consistent.

use reqwest::ClientBuilder;
use thiserror::Error;

/// Resolved proxy policy applied to every HTTP client.
///
/// The policy carries explicit overrides plus the user's intent
/// ("disable"). When [`disabled`](Self::disabled) is true the builder
/// turns off reqwest's automatic env-var pickup; otherwise reqwest's
/// default behaviour (read `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`
/// from the environment) is preserved.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ProxyConfig {
    /// Catch-all proxy URL applied to both HTTP and HTTPS schemes.
    pub all: Option<String>,
    /// HTTP-only proxy URL. Wins over [`all`](Self::all) for HTTP
    /// targets.
    pub http: Option<String>,
    /// HTTPS-only proxy URL. Wins over [`all`](Self::all) for HTTPS
    /// targets.
    pub https: Option<String>,
    /// Comma-separated host or domain patterns to bypass. Mirrors
    /// `NO_PROXY` semantics: an exact host match or a leading-dot
    /// suffix match (`.example.com`) skips the proxy.
    pub bypass: Vec<String>,
    /// When true the client explicitly skips every proxy, including
    /// env-var pickup. Mutually exclusive with the URL fields; the
    /// parser rejects the combination so this struct only needs to
    /// observe it.
    pub disabled: bool,
}

/// Failure modes for [`ProxyConfig::apply`].
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ProxyError {
    /// `--no-proxy` was combined with `--proxy`, `--http-proxy`, or
    /// `--https-proxy`. The CLI rejects this combination at parse
    /// time, but library callers also need a clear error.
    #[error("--no-proxy cannot be combined with --proxy / --http-proxy / --https-proxy")]
    ConflictingDisable,
    /// reqwest rejected the proxy URL (invalid scheme, malformed
    /// authority, etc.). Carries the proxy role so the user can spot
    /// which flag was wrong.
    #[error("invalid {role} proxy URL `{url}`: {reason}")]
    InvalidProxyUrl {
        /// Which flag the URL came from (`http`, `https`, or `all`).
        role: &'static str,
        /// The URL string as supplied.
        url: String,
        /// Underlying parser message from reqwest.
        reason: String,
    },
}

impl ProxyConfig {
    /// True when no explicit proxy flag was set. The reqwest default
    /// (env-var pickup) applies in that case, so the user behind a
    /// corporate proxy still gets the right behaviour just by exporting
    /// `HTTPS_PROXY` in their shell.
    pub fn is_empty(&self) -> bool {
        self.all.is_none()
            && self.http.is_none()
            && self.https.is_none()
            && self.bypass.is_empty()
            && !self.disabled
    }

    /// Apply the configured proxies to a reqwest [`ClientBuilder`].
    /// Returns the builder unchanged when [`is_empty`](Self::is_empty)
    /// so callers can chain without conditional plumbing.
    pub fn apply(&self, builder: ClientBuilder) -> Result<ClientBuilder, ProxyError> {
        if self.disabled && (self.all.is_some() || self.http.is_some() || self.https.is_some()) {
            return Err(ProxyError::ConflictingDisable);
        }
        if self.disabled {
            return Ok(builder.no_proxy());
        }
        if self.is_empty() {
            return Ok(builder);
        }
        let mut builder = builder;
        if let Some(url) = &self.all {
            let proxy = reqwest::Proxy::all(url).map_err(|e| ProxyError::InvalidProxyUrl {
                role: "all",
                url: url.clone(),
                reason: e.to_string(),
            })?;
            builder = builder.proxy(apply_bypass(proxy, &self.bypass));
        }
        if let Some(url) = &self.http {
            let proxy = reqwest::Proxy::http(url).map_err(|e| ProxyError::InvalidProxyUrl {
                role: "http",
                url: url.clone(),
                reason: e.to_string(),
            })?;
            builder = builder.proxy(apply_bypass(proxy, &self.bypass));
        }
        if let Some(url) = &self.https {
            let proxy = reqwest::Proxy::https(url).map_err(|e| ProxyError::InvalidProxyUrl {
                role: "https",
                url: url.clone(),
                reason: e.to_string(),
            })?;
            builder = builder.proxy(apply_bypass(proxy, &self.bypass));
        }
        Ok(builder)
    }

    /// One-line human summary used by `mcptest doctor` and
    /// `mcptest run --print-config`. Returns `"default (env)"` when no
    /// flag is set so the user knows reqwest is still reading
    /// `HTTPS_PROXY` and friends from the shell.
    pub fn summary(&self) -> String {
        if self.disabled {
            return "disabled (--no-proxy)".to_string();
        }
        if self.is_empty() {
            return "default (env: HTTP_PROXY / HTTPS_PROXY / NO_PROXY)".to_string();
        }
        let mut parts: Vec<String> = Vec::new();
        if let Some(url) = &self.all {
            parts.push(format!("all={url}"));
        }
        if let Some(url) = &self.http {
            parts.push(format!("http={url}"));
        }
        if let Some(url) = &self.https {
            parts.push(format!("https={url}"));
        }
        if !self.bypass.is_empty() {
            parts.push(format!("bypass={}", self.bypass.join(",")));
        }
        parts.join(", ")
    }
}

/// Attach a bypass list to a reqwest [`Proxy`] using a closure that
/// matches the same `NO_PROXY` rules curl uses: exact host match or
/// leading-dot suffix.
fn apply_bypass(proxy: reqwest::Proxy, bypass: &[String]) -> reqwest::Proxy {
    if bypass.is_empty() {
        return proxy;
    }
    let patterns: Vec<String> = bypass.iter().map(|s| s.to_ascii_lowercase()).collect();
    proxy.no_proxy(reqwest::NoProxy::from_string(&patterns.join(",")))
}

/// Parse a comma-separated `--noproxy` list into a vec of trimmed
/// entries with empties dropped. Standalone so the CLI value parser
/// can call it without depending on reqwest.
pub fn parse_bypass_list(raw: &str) -> Vec<String> {
    raw.split(',')
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect()
}

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

    #[test]
    fn empty_config_returns_builder_unchanged() {
        let cfg = ProxyConfig::default();
        let builder = reqwest::Client::builder();
        let _ = cfg.apply(builder).expect("default config applies");
    }

    #[test]
    fn disabled_with_url_returns_conflict() {
        let cfg = ProxyConfig {
            disabled: true,
            all: Some("http://proxy.example:8080".into()),
            ..ProxyConfig::default()
        };
        let err = cfg.apply(reqwest::Client::builder()).expect_err("conflict");
        assert!(matches!(err, ProxyError::ConflictingDisable));
    }

    #[test]
    fn invalid_url_returns_invalid_proxy_url() {
        let cfg = ProxyConfig {
            all: Some("not a url".into()),
            ..ProxyConfig::default()
        };
        let err = cfg
            .apply(reqwest::Client::builder())
            .expect_err("invalid url");
        match err {
            ProxyError::InvalidProxyUrl { role, .. } => assert_eq!(role, "all"),
            other => panic!("expected InvalidProxyUrl, got {other:?}"),
        }
    }

    #[test]
    fn summary_renders_each_role() {
        let cfg = ProxyConfig {
            all: Some("http://proxy:8080".into()),
            https: Some("https://proxy:8443".into()),
            bypass: vec!["localhost".into(), ".internal".into()],
            ..ProxyConfig::default()
        };
        let summary = cfg.summary();
        assert!(summary.contains("all=http://proxy:8080"));
        assert!(summary.contains("https=https://proxy:8443"));
        assert!(summary.contains("bypass=localhost,.internal"));
    }

    #[test]
    fn summary_default_mentions_env_vars() {
        let cfg = ProxyConfig::default();
        assert!(cfg.summary().contains("HTTPS_PROXY"));
    }

    #[test]
    fn summary_disabled_is_explicit() {
        let cfg = ProxyConfig {
            disabled: true,
            ..ProxyConfig::default()
        };
        assert_eq!(cfg.summary(), "disabled (--no-proxy)");
    }

    #[test]
    fn parse_bypass_list_drops_empties_and_trims() {
        assert_eq!(
            parse_bypass_list("localhost, , .internal,"),
            vec!["localhost".to_string(), ".internal".to_string()]
        );
    }
}
