//! SSRF URL validation helpers for outbound HTTP connections.
//!
//! Consistent with the guards in `fraiseql-federation` and `fraiseql-core`.

use crate::error::ObserverError;

/// Validate that a URL is safe for outbound HTTP contact.
///
/// Rejects URLs targeting private/loopback/link-local IP addresses to prevent
/// server-side request forgery via misconfigured or attacker-controlled URLs.
///
/// # Errors
///
/// Returns `ObserverError::InvalidConfig` if the URL is unparseable or targets
/// a forbidden host.
pub fn validate_outbound_url(url: &str) -> crate::error::Result<()> {
    // The `FRAISEQL_OBSERVERS_ALLOW_INSECURE` bypass is honored only in
    // development environments; refused when any production marker is set.
    // See `crate::insecure_guard` for the full policy.
    if crate::insecure_guard::is_outbound_insecure_allowed() {
        return Ok(());
    }

    let parsed = reqwest::Url::parse(url).map_err(|e| ObserverError::InvalidConfig {
        message: format!("Invalid URL '{url}': {e}"),
    })?;

    let host_raw = parsed.host_str().ok_or_else(|| ObserverError::InvalidConfig {
        message: format!("URL has no host: {url}"),
    })?;

    // Strip IPv6 brackets added by the url crate (e.g. "[::1]" → "::1").
    let host = if host_raw.starts_with('[') && host_raw.ends_with(']') {
        &host_raw[1..host_raw.len() - 1]
    } else {
        host_raw
    };

    // Block loopback hostnames before attempting IP parsing.
    // Covers: `localhost`, `subdomain.localhost`, `localhost.localdomain`,
    // and other `localhost.*` aliases common in /etc/hosts configurations.
    let lower_host = host.to_ascii_lowercase();
    if lower_host == "localhost"
        || lower_host.ends_with(".localhost")
        || lower_host.starts_with("localhost.")
    {
        return Err(ObserverError::InvalidConfig {
            message: format!("URL targets a loopback host ({host}) — SSRF protection blocked"),
        });
    }

    if let Ok(ip) = host.parse::<std::net::IpAddr>() {
        if is_ssrf_blocked_ip(&ip) {
            return Err(ObserverError::InvalidConfig {
                message: format!(
                    "URL targets a private/reserved IP ({ip}) — SSRF protection blocked"
                ),
            });
        }
    }

    Ok(())
}

/// Resolve the host via DNS and reject if any address is private/reserved.
///
/// Prevents DNS rebinding attacks where an attacker-controlled domain initially
/// resolves to a public IP (passing URL validation) but later resolves to a
/// private IP during the actual HTTP request.
///
/// # Errors
///
/// Returns `ObserverError::InvalidConfig` if DNS resolution fails, returns no
/// addresses, or any resolved address is in a private/reserved range.
pub async fn dns_resolve_and_check(url: &str) -> crate::error::Result<()> {
    // The `FRAISEQL_OBSERVERS_ALLOW_INSECURE` bypass is honored only in
    // development environments; refused when any production marker is set.
    // See `crate::insecure_guard` for the full policy.
    if crate::insecure_guard::is_outbound_insecure_allowed() {
        return Ok(());
    }

    let parsed = reqwest::Url::parse(url).map_err(|e| ObserverError::InvalidConfig {
        message: format!("Invalid URL '{url}': {e}"),
    })?;
    let host = parsed.host_str().ok_or_else(|| ObserverError::InvalidConfig {
        message: format!("URL has no host: {url}"),
    })?;
    let port = parsed.port_or_known_default().unwrap_or(443);
    let addrs: Vec<std::net::SocketAddr> = tokio::net::lookup_host((host, port))
        .await
        .map_err(|e| ObserverError::InvalidConfig {
            message: format!("DNS resolution failed for host '{host}': {e}"),
        })?
        .collect();
    if addrs.is_empty() {
        return Err(ObserverError::InvalidConfig {
            message: format!("DNS resolved to no addresses for host '{host}'"),
        });
    }
    for addr in &addrs {
        if is_ssrf_blocked_ip(&addr.ip()) {
            return Err(ObserverError::InvalidConfig {
                message: format!(
                    "DNS rebinding attack blocked: host '{host}' resolved to private/reserved IP {}",
                    addr.ip()
                ),
            });
        }
    }
    Ok(())
}

/// Validate that a NATS URL is safe to connect to.
///
/// Accepts `tls://` always; accepts plaintext `nats://` only when the operator
/// has opted in via `FRAISEQL_NATS_ALLOW_PLAINTEXT` in a non-production
/// environment (L-nats-plaintext). Rejects every other scheme and any
/// private/loopback host.
///
/// # Errors
///
/// Returns `ObserverError::InvalidConfig` if the URL uses an unsupported scheme,
/// uses plaintext `nats://` without an explicit opt-in, or targets a forbidden host.
#[cfg(feature = "nats")]
pub fn validate_nats_url(url: &str) -> crate::error::Result<()> {
    let is_tls = url.starts_with("tls://");
    let is_plaintext = url.starts_with("nats://");
    if !is_tls && !is_plaintext {
        return Err(ObserverError::InvalidConfig {
            message: format!("NATS URL must use nats:// or tls:// scheme (got: {url})"),
        });
    }
    // L-nats-plaintext: plaintext nats:// has no transport encryption — change-log
    // events would cross the wire in the clear. Refuse it by default; the
    // `FRAISEQL_NATS_ALLOW_PLAINTEXT` escape hatch is honoured only outside
    // production (see `crate::insecure_guard`). tls:// is always allowed.
    if is_plaintext && !crate::insecure_guard::is_nats_plaintext_allowed() {
        return Err(ObserverError::InvalidConfig {
            message: format!(
                "NATS URL uses plaintext nats:// without TLS (got: {url}). Use tls:// for an \
                 encrypted connection, or set {}=true in a non-production environment to allow \
                 plaintext.",
                crate::insecure_guard::NATS_ALLOW_PLAINTEXT_ENV
            ),
        });
    }
    validate_outbound_url(url)
}

/// Returns `true` for IP ranges that outbound connections must never target.
fn is_ssrf_blocked_ip(ip: &std::net::IpAddr) -> bool {
    match ip {
        std::net::IpAddr::V4(v4) => {
            let o = v4.octets();
            o[0] == 127                                          // loopback 127/8
            || o[0] == 10                                        // RFC 1918 10/8
            || (o[0] == 172 && (16..=31).contains(&o[1]))       // RFC 1918 172.16/12
            || (o[0] == 192 && o[1] == 168)                     // RFC 1918 192.168/16
            || (o[0] == 169 && o[1] == 254)                     // link-local 169.254/16
            || (o[0] == 100 && (64..=127).contains(&o[1]))      // CGNAT 100.64/10
            || o[0] == 0 // 0.0.0.0/8 "this network" (covers 0.0.0.0 unspecified)
        },
        std::net::IpAddr::V6(v6) => {
            v6.is_loopback()                                     // ::1
            || v6.is_unspecified()                               // ::
            || {
                let s = v6.segments();
                (s[0] & 0xfe00) == 0xfc00                        // ULA fc00::/7
                || (s[0] & 0xffc0) == 0xfe80                    // link-local fe80::/10
                || (s[0] == 0 && s[1] == 0 && s[2] == 0        // ::ffff:0:0/96
                    && s[3] == 0 && s[4] == 0 && s[5] == 0xffff)
            }
        },
    }
}

#[cfg(test)]
#[allow(clippy::unwrap_used)] // Reason: tests use unwrap for concise assertions
mod ssrf_tests {
    use super::*;

    // Coverage retained when the drifted `dispatch.rs` copy was deleted and the
    // webhook dispatch path converged onto this canonical guard.

    #[test]
    fn rejects_localhost_dot_prefix_alias() {
        // `localhost.evil.com` and similar `localhost.` prefix aliases must be
        // rejected — this check existed only in the deleted dispatch.rs copy.
        let result = validate_outbound_url("https://localhost.evil.com/hook");
        assert!(
            result.is_err(),
            "localhost.evil.com must be rejected as a loopback alias: {result:?}"
        );
    }

    #[test]
    fn rejects_localhost_suffix_and_bare() {
        assert!(validate_outbound_url("https://localhost/hook").is_err());
        assert!(validate_outbound_url("https://api.localhost/hook").is_err());
    }

    #[test]
    fn rejects_zero_network_range() {
        // 0.0.0.0/8 "this network" — the dispatch.rs copy blocked the whole /8
        // via `o[0] == 0`; the canonical guard previously only blocked the exact
        // 0.0.0.0 address.
        assert!(validate_outbound_url("https://0.0.0.0/hook").is_err());
        assert!(
            validate_outbound_url("https://0.1.2.3/hook").is_err(),
            "0.0.0.0/8 range must be blocked, not just the unspecified address"
        );
    }

    #[test]
    fn allows_public_host_and_ip() {
        assert!(validate_outbound_url("https://api.example.com/hook").is_ok());
        assert!(validate_outbound_url("https://8.8.8.8/hook").is_ok());
    }

    #[test]
    fn rejects_private_and_loopback_ips() {
        assert!(validate_outbound_url("https://127.0.0.1/hook").is_err());
        assert!(validate_outbound_url("https://10.0.0.1/hook").is_err());
        assert!(validate_outbound_url("https://192.168.1.1/hook").is_err());
        assert!(validate_outbound_url("https://169.254.169.254/hook").is_err());
        assert!(validate_outbound_url("https://[::1]/hook").is_err());
    }
}
