//! # public_url — reconstruct the URL as the CPaaS saw it
//! (deploy spec §3.1 invariant 5; deploy slice A §5.3)
//!
//! `X-Twilio-Signature` is an HMAC over the URL *as Twilio requested it*
//! — `https://pbx.example.com/v1/trunk/webhook` — while a FOB behind a
//! TLS-terminating edge sees `http://10.0.0.5:8080/v1/trunk/webhook`.
//! Validation must rebuild the public URL from the edge's
//! `X-Forwarded-Proto`/`X-Forwarded-Host`… but ONLY when the direct TCP
//! peer is the operator-configured edge (`RUTSTER_TRUSTED_PROXIES`).
//! Honoring those headers from arbitrary peers would let an attacker
//! choose the exact URL their own forged signature is checked against.
//!
//! ## Scope (honest)
//!
//! Signature validation itself does NOT exist yet — the trunk webhook is
//! a slice-5-era handler with no HMAC anywhere in the tree. This module
//! lands the trusted-proxy half with tests so the trunk slice consumes
//! it instead of growing its own. It is a pure function over extracted
//! values (no axum types) — the future caller extracts the peer IP
//! (`ConnectInfo`) and header strings and gets back the URL to HMAC.
//!
//! Fail-closed contract: `Err` from this function means "the request's
//! public URL cannot be trusted" — a signature-validating caller must
//! reject the request, never fall back silently.

use std::net::IpAddr;

use ipnet::IpNet;
use url::Url;

/// Rebuild the public URL for a request that arrived from `peer`.
///
/// - `peer` ∉ `trusted_proxies` (including the empty-list default):
///   forwarded headers are IGNORED — the URL is `fallback_base` +
///   `path_and_query`. An untrusted peer never influences the result.
/// - `peer` ∈ `trusted_proxies` and BOTH headers present: the URL is
///   `{proto}://{host}{path_and_query}`. Chained proxies append
///   comma-separated values; the FIRST entry is what the outermost edge
///   (the hop the CPaaS actually dialed) saw, so that one wins.
/// - `peer` trusted but either header missing: fall back to
///   `fallback_base` — a half-forwarding edge must not half-win.
/// - Trusted peer + malformed values (non-http(s) proto, unparseable
///   host): `Err` — fail closed, see module docs.
///
/// `path_and_query` is the request's origin-form target, e.g.
/// `/v1/trunk/webhook` or `/v1/trunk/webhook?a=b` (Twilio signs query
/// strings too on webhooks — Media Streams URLs never carry them, spec
/// §3.1 invariant 4).
pub fn reconstruct_public_url(
    peer: IpAddr,
    forwarded_proto: Option<&str>,
    forwarded_host: Option<&str>,
    trusted_proxies: &[IpNet],
    fallback_base: &Url,
    path_and_query: &str,
) -> Result<Url, String> {
    let peer_trusted = trusted_proxies.iter().any(|net| net.contains(&peer));
    if peer_trusted {
        if let (Some(proto), Some(host)) = (forwarded_proto, forwarded_host) {
            let proto = proto.split(',').next().unwrap_or("").trim();
            let host = host.split(',').next().unwrap_or("").trim();
            if proto != "http" && proto != "https" {
                return Err(format!(
                    "X-Forwarded-Proto {proto:?} from trusted peer {peer} is not http(s)"
                ));
            }
            return Url::parse(&format!("{proto}://{host}{path_and_query}"))
                .map_err(|e| format!("forwarded public URL does not parse: {e}"));
        }
        // Trusted peer, incomplete headers: fall through to the fallback.
    }
    fallback_base
        .join(path_and_query)
        .map_err(|e| format!("fallback URL join: {e}"))
}

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

    fn base() -> Url {
        Url::parse("https://pbx.example.com").unwrap()
    }

    fn edge_net() -> Vec<IpNet> {
        vec!["10.0.0.0/24".parse().unwrap()]
    }

    #[test]
    fn untrusted_peer_headers_are_ignored() {
        let u = reconstruct_public_url(
            "203.0.113.9".parse().unwrap(),
            Some("https"),
            Some("evil.example.net"),
            &edge_net(),
            &base(),
            "/v1/trunk/webhook",
        )
        .unwrap();
        assert_eq!(u.as_str(), "https://pbx.example.com/v1/trunk/webhook");
    }

    #[test]
    fn empty_trusted_list_default_ignores_headers() {
        // RUTSTER_TRUSTED_PROXIES unset → empty list → headers IGNORED,
        // even from loopback (deploy spec §5.7: empty default is the
        // fail-closed posture, not a convenience).
        let u = reconstruct_public_url(
            "127.0.0.1".parse().unwrap(),
            Some("https"),
            Some("evil.example.net"),
            &[],
            &base(),
            "/v1/trunk/webhook",
        )
        .unwrap();
        assert_eq!(u.as_str(), "https://pbx.example.com/v1/trunk/webhook");
    }

    #[test]
    fn trusted_peer_headers_are_honored_with_port_and_query() {
        let u = reconstruct_public_url(
            "10.0.0.7".parse().unwrap(),
            Some("https"),
            Some("pbx.example.com:8443"),
            &edge_net(),
            &base(),
            "/v1/trunk/webhook?CallSid=CA123",
        )
        .unwrap();
        assert_eq!(
            u.as_str(),
            "https://pbx.example.com:8443/v1/trunk/webhook?CallSid=CA123"
        );
    }

    #[test]
    fn trusted_peer_comma_chained_values_take_the_outermost() {
        let u = reconstruct_public_url(
            "10.0.0.7".parse().unwrap(),
            Some("https, http"),
            Some("pbx.example.com, internal.local"),
            &edge_net(),
            &base(),
            "/v1/trunk/webhook",
        )
        .unwrap();
        assert_eq!(u.as_str(), "https://pbx.example.com/v1/trunk/webhook");
    }

    #[test]
    fn trusted_peer_with_missing_header_falls_back() {
        let u = reconstruct_public_url(
            "10.0.0.7".parse().unwrap(),
            Some("https"),
            None,
            &edge_net(),
            &base(),
            "/v1/trunk/webhook",
        )
        .unwrap();
        assert_eq!(u.as_str(), "https://pbx.example.com/v1/trunk/webhook");
    }

    #[test]
    fn trusted_peer_with_garbage_proto_fails_closed() {
        let err = reconstruct_public_url(
            "10.0.0.7".parse().unwrap(),
            Some("gopher"),
            Some("pbx.example.com"),
            &edge_net(),
            &base(),
            "/v1/trunk/webhook",
        )
        .unwrap_err();
        assert!(err.contains("X-Forwarded-Proto"), "msg: {err}");
    }
}
