//! # config — pure env-parsing helpers (slice-5)
//!
//! Every knob is a pure function over `Option<String>` / `&str` so tests
//! never mutate process env (env mutation in tests races across the
//! parallel test harness — the same reason `api_key.rs` needs its
//! ENV_MUTEX). `main.rs` is the only caller that touches `std::env`.
//!
//! Closes 2026-07-04 scalability review "HTTP bind hardcoded" (minor).

use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;

use rutster_media::MediaAddressConfig;
use rutster_trunk::provider::TwilioCredentials;
use url::Url;

/// Resolve the HTTP bind address from `RUTSTER_HTTP_BIND`.
///
/// `None` → the historical default `0.0.0.0:8080`. Invalid input is a
/// hard error (fail-fast at startup — an operator typo must not silently
/// bind the default and hide behind an LB health check).
pub fn http_bind(raw: Option<String>) -> Result<SocketAddr, String> {
    match raw {
        None => Ok("0.0.0.0:8080".parse().expect("static default parses")),
        Some(s) => s
            .parse()
            .map_err(|e| format!("RUTSTER_HTTP_BIND {s:?} is not a socket address: {e}")),
    }
}

/// Parse `RUTSTER_MEDIA_PORT_RANGE` — `"lo-hi"`, inclusive, lo ≤ hi.
pub fn parse_port_range(raw: &str) -> Result<(u16, u16), String> {
    let (lo, hi) = raw
        .split_once('-')
        .ok_or_else(|| format!("RUTSTER_MEDIA_PORT_RANGE {raw:?}: expected \"lo-hi\""))?;
    let lo: u16 = lo
        .trim()
        .parse()
        .map_err(|e| format!("RUTSTER_MEDIA_PORT_RANGE lo: {e}"))?;
    let hi: u16 = hi
        .trim()
        .parse()
        .map_err(|e| format!("RUTSTER_MEDIA_PORT_RANGE hi: {e}"))?;
    if lo > hi {
        return Err(format!("RUTSTER_MEDIA_PORT_RANGE {raw:?}: lo > hi"));
    }
    Ok((lo, hi))
}

/// Assemble `MediaAddressConfig` from the three `RUTSTER_MEDIA_*` vars.
pub fn media_address_config(
    bind_ip: Option<String>,
    advertised_ip: Option<String>,
    port_range: Option<String>,
) -> Result<MediaAddressConfig, String> {
    let mut cfg = MediaAddressConfig::default();
    if let Some(s) = bind_ip {
        cfg.bind_ip = s
            .parse::<IpAddr>()
            .map_err(|e| format!("RUTSTER_MEDIA_BIND_IP: {e}"))?;
    }
    if let Some(s) = advertised_ip {
        cfg.advertised_ip = Some(
            s.parse::<IpAddr>()
                .map_err(|e| format!("RUTSTER_MEDIA_ADVERTISED_IP: {e}"))?,
        );
    }
    if let Some(s) = port_range {
        cfg.port_range = Some(parse_port_range(&s)?);
    }
    Ok(cfg)
}

/// `RUTSTER_MAX_SESSIONS` — admission cap. Default 64 (placeholder until
/// the ADR-0010 benchmark measures the real per-node ceiling).
pub fn max_sessions(raw: Option<String>) -> Result<usize, String> {
    match raw {
        None => Ok(64),
        Some(s) => s
            .parse()
            .map_err(|e| format!("RUTSTER_MAX_SESSIONS {s:?}: {e}")),
    }
}

/// `RUTSTER_DRAIN_DEADLINE_SECS` — how long shutdown waits for in-flight
/// calls before the hard stop. Default 0 = today's instant shutdown (dev
/// loop); production sets minutes. Calls are non-migratable by design, so
/// drain-then-terminate is the ONLY graceful scale-in shape.
pub fn drain_deadline(raw: Option<String>) -> Result<std::time::Duration, String> {
    match raw {
        None => Ok(std::time::Duration::ZERO),
        Some(s) => s
            .parse::<u64>()
            .map(std::time::Duration::from_secs)
            .map_err(|e| format!("RUTSTER_DRAIN_DEADLINE_SECS {s:?}: {e}")),
    }
}

/// `RUTSTER_WS_PING_SECS` — engine-originated app-level WS ping interval
/// on long-lived WebSockets. Today that is exactly one surface: the trunk
/// media-stream WS (WebRTC signaling is plain HTTP). Default 20 s.
///
/// Belt-and-braces against every idle timer between the FOB and the
/// CPaaS: neither Twilio nor Telnyx documents WS keepalive of their own,
/// so keepalive is entirely our job (deploy spec §3.1 invariant 3), and
/// the caller→engine direction may be the only traffic for hours.
///
/// `0` is rejected fail-fast: it would busy-loop the ping timer. There is
/// deliberately no "off" spelling — an operator who wants no pings is an
/// operator about to learn about 60 s proxy idle defaults mid-call.
pub fn ws_ping_interval(raw: Option<String>) -> Result<std::time::Duration, String> {
    match raw {
        None => Ok(std::time::Duration::from_secs(20)),
        Some(s) => {
            let secs: u64 = s
                .parse()
                .map_err(|e| format!("RUTSTER_WS_PING_SECS {s:?}: {e}"))?;
            if secs == 0 {
                return Err(
                    "RUTSTER_WS_PING_SECS must be >= 1 (0 would busy-loop the ping timer)"
                        .to_string(),
                );
            }
            Ok(std::time::Duration::from_secs(secs))
        }
    }
}

/// `RUTSTER_TRUSTED_PROXIES` — comma-separated CIDR list of edge peers
/// whose `X-Forwarded-Proto`/`X-Forwarded-Host` are believed
/// (deploy slice A §5.3; consumed via
/// `rutster_trunk::public_url::reconstruct_public_url`).
///
/// Empty or unset ⇒ empty list ⇒ forwarded headers IGNORED — fail-closed:
/// an internet peer must never choose the public URL that Twilio
/// signature validation reconstructs (deploy spec §3.1 invariant 5).
/// Bare IPs are accepted as host-length prefixes
/// (`10.0.0.7` ≡ `10.0.0.7/32`).
pub fn trusted_proxies(raw: Option<String>) -> Result<Vec<ipnet::IpNet>, String> {
    let Some(s) = raw else {
        return Ok(Vec::new());
    };
    let mut nets = Vec::new();
    for entry in s.split(',') {
        let entry = entry.trim();
        if entry.is_empty() {
            continue;
        }
        let net = entry
            .parse::<ipnet::IpNet>()
            .or_else(|_| entry.parse::<IpAddr>().map(ipnet::IpNet::from))
            .map_err(|e| format!("RUTSTER_TRUSTED_PROXIES entry {entry:?}: {e}"))?;
        nets.push(net);
    }
    Ok(nets)
}

/// Parse the four `RUTSTER_TWILIO_*` env vars into a [`TwilioCredentials`].
///
/// Returns `Ok(None)` when ALL four vars are unset — the binary runs WebRTC-only
/// (slice-1–3 ingress + slice-4 barge-in + slice-4½ sim) without trunk support.
/// Returns `Ok(Some(creds))` when all four are present + parse cleanly. Returns
/// `Err(msg)` on partial config (some vars set, some unset — an operator
/// misconfiguration that must fail-fast at startup, not silently run WebRTC-only)
/// or on malformed values (a non-parseable `MEDIA_BIND` or `WEBHOOK_BASE`).
///
/// Pure function over `Option<String>` inputs (matching the rest of this module's
/// convention) so unit tests never mutate process env. `main.rs` is the only caller
/// that touches `std::env` — it passes the `std::env::var(...).ok()` results here.
///
/// `TwilioCredentials` lives in `rutster-trunk` (ADR-0009 — provider credentials
/// never reach the brain); this parser only constructs the struct, it does not
/// re-export it through the workspace.
///
/// The match destructures all four `Option`s at once: the `(None,None,None,None)`
/// arm is the all-unset → disabled case; the `(Some,Some,Some,Some)` arm extracts
/// owned `String`s (so no `.unwrap()` — the pattern itself proves non-`None`); the
/// catch-all arm reports which subset was set, for an actionable operator error.
pub fn twilio_credentials(
    account_sid: Option<String>,
    auth_token: Option<String>,
    media_streams_bind: Option<String>,
    webhook_base: Option<String>,
) -> Result<Option<TwilioCredentials>, String> {
    let (account_sid, auth_token, media_streams_bind_raw, webhook_base_raw) =
        match (account_sid, auth_token, media_streams_bind, webhook_base) {
            (None, None, None, None) => return Ok(None),
            (Some(a), Some(t), Some(m), Some(w)) => (a, t, m, w),
            (a, t, m, w) => {
                let mut got: Vec<&str> = Vec::new();
                if a.is_some() {
                    got.push("RUTSTER_TWILIO_ACCOUNT_SID");
                }
                if t.is_some() {
                    got.push("RUTSTER_TWILIO_AUTH_TOKEN");
                }
                if m.is_some() {
                    got.push("RUTSTER_TWILIO_MEDIA_BIND");
                }
                if w.is_some() {
                    got.push("RUTSTER_TWILIO_WEBHOOK_BASE");
                }
                return Err(format!(
                    "partial Twilio config: set all four RUTSTER_TWILIO_* vars or none \
                     (got only: {})",
                    got.join(", ")
                ));
            }
        };

    let media_streams_bind = media_streams_bind_raw
        .parse::<SocketAddr>()
        .map_err(|e| format!("RUTSTER_TWILIO_MEDIA_BIND is not a socket address: {e}"))?;
    let webhook_base = webhook_base_raw
        .parse::<Url>()
        .map_err(|e| format!("RUTSTER_TWILIO_WEBHOOK_BASE is not a URL: {e}"))?;
    // Fail-fast: the webhook base's scheme drives the <Stream url=...>
    // derivation in routes::twilio_inbound_webhook. A schemeless input
    // like "pbx.example.com:8080" parses successfully as a URL (scheme
    // becomes "pbx.example.com") but would 500 every webhook at runtime
    // — fail at boot instead, naming the var.
    let scheme = webhook_base.scheme();
    if scheme != "http" && scheme != "https" {
        return Err(format!(
            "RUTSTER_TWILIO_WEBHOOK_BASE scheme must be http or https, got {scheme:?}"
        ));
    }
    // XML-specials guard: the webhook base is interpolated into the
    // TwiML response body (routes::twilio_inbound_webhook) without an
    // XML-escape pass. The `url` crate percent-encodes `"` `<` `>` in
    // authorities/queries/fragments, but `&` survives literally as a
    // path char (e.g. `&amp` parses to `/&amp`); reject any literal
    // `&` in the URL's `as_str()` to keep the TwiML body well-formed.
    // The `'` char is NOT rejected: the TwiML body uses `"` as its
    // attribute delimiter, so `'` is a valid path character.
    let as_str = webhook_base.as_str();
    if as_str.contains('&') {
        return Err("RUTSTER_TWILIO_WEBHOOK_BASE contains XML-special char '&' \
             (literal ampersand in path/query); rejected to keep TwiML body well-formed"
            .to_string());
    }

    Ok(Some(TwilioCredentials {
        account_sid,
        auth_token,
        media_streams_bind,
        webhook_base,
    }))
}

/// `RUTSTER_TLS_CERT` — filesystem path to the TLS certificate (PEM) used by
/// the axum HTTPS surface (deploy slice C §5.4).
///
/// `None` means the operator did not enable HTTPS; `main.rs` will serve plain
/// HTTP. `Some(path)` is validated to point at an existing regular file so a
/// typo fails-fast at startup instead of failing mid-request.
///
/// This is a pure function over `Option<String>` (not a direct `std::env::var`
/// call) so unit tests never mutate process env and never touch the real
/// filesystem unless the test itself creates a tempfile.
pub fn tls_cert_path(raw: Option<String>) -> Result<Option<PathBuf>, String> {
    match raw {
        None => Ok(None),
        Some(s) => {
            let p = PathBuf::from(s);
            if !p.is_file() {
                return Err(format!(
                    "RUTSTER_TLS_CERT {:?}: file does not exist (or is not a regular file)",
                    p.display()
                ));
            }
            Ok(Some(p))
        }
    }
}

/// `RUTSTER_TLS_KEY` — filesystem path to the TLS private key (PEM) paired
/// with [`tls_cert_path`].
///
/// `None` means HTTPS is disabled; `main.rs` will serve plain HTTP. When both
/// `RUTSTER_TLS_CERT` and `RUTSTER_TLS_KEY` are `Some`, `main.rs` feeds the
/// pair to `tokio_rustls` (or equivalent) to terminate TLS on the signaling
/// surface. The mutual presence check lives in `main.rs`, not here — this
/// parser only validates its own path.
///
/// Like the other parsers in this module, the function is pure over
/// `Option<String>` so tests stay deterministic and env-free.
pub fn tls_key_path(raw: Option<String>) -> Result<Option<PathBuf>, String> {
    match raw {
        None => Ok(None),
        Some(s) => {
            let p = PathBuf::from(s);
            if !p.is_file() {
                return Err(format!(
                    "RUTSTER_TLS_KEY {:?}: file does not exist (or is not a regular file)",
                    p.display()
                ));
            }
            Ok(Some(p))
        }
    }
}

/// Resolve the metrics bind address from `RUTSTER_METRICS_BIND`.
///
/// `None` → loopback-only `127.0.0.1:9090`. The separate listener
/// invariant (deploy-C §5.5) means this endpoint must never be reached
/// through the TLS-terminating edge, so the default is intentionally
/// localhost-only — operators who want Prometheus to scrape from another
/// host must set the variable explicitly.
pub fn metrics_bind(raw: Option<String>) -> Result<SocketAddr, String> {
    match raw {
        None => Ok("127.0.0.1:9090".parse().expect("static default parses")),
        Some(s) => s
            .parse()
            .map_err(|e| format!("RUTSTER_METRICS_BIND {s:?} is not a socket address: {e}")),
    }
}

/// `RUTSTER_VALKEY_URL` — optional Valkey event-bus endpoint.
///
/// Returns `Ok(None)` when unset (the binary keeps the default
/// [`TracingEventSink`]). When set, the scheme MUST be `redis://` —
/// `rediss://` (TLS) is rejected at boot because the redis crate's TLS
/// feature is not compiled in (spec §3.4 staging; rustls Phase 2 is
/// trigger-gated). Anything else is a fail-fast operator error. The `Url`
/// parser is reused (already a workspace dep) so malformed input names
/// the variable in the error.
pub fn valkey_url(raw: Option<String>) -> Result<Option<Url>, String> {
    match raw {
        None => Ok(None),
        Some(s) => {
            let u = s
                .parse::<Url>()
                .map_err(|e| format!("RUTSTER_VALKEY_URL {s:?}: {e}"))?;
            match u.scheme() {
                "redis" => Ok(Some(u)),
                "rediss" => Err(format!(
                    "RUTSTER_VALKEY_URL {s:?}: rediss:// requires a redis TLS feature that is \
                     not compiled (see spec §3.4 staging); use redis://"
                )),
                other => Err(format!(
                    "RUTSTER_VALKEY_URL {s:?}: scheme must be redis:// (got {other:?})"
                )),
            }
        }
    }
}

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

    #[test]
    fn http_bind_defaults_when_unset() {
        assert_eq!(
            http_bind(None).unwrap(),
            "0.0.0.0:8080".parse::<SocketAddr>().unwrap()
        );
    }

    #[test]
    fn http_bind_parses_override() {
        assert_eq!(
            http_bind(Some("127.0.0.1:9090".into())).unwrap(),
            "127.0.0.1:9090".parse::<SocketAddr>().unwrap()
        );
    }

    #[test]
    fn http_bind_rejects_garbage_with_var_name_in_error() {
        let err = http_bind(Some("not-an-addr".into())).unwrap_err();
        assert!(err.contains("RUTSTER_HTTP_BIND"));
    }

    #[test]
    fn metrics_bind_defaults_to_loopback_9090_when_unset() {
        assert_eq!(
            metrics_bind(None).unwrap(),
            "127.0.0.1:9090".parse::<SocketAddr>().unwrap()
        );
    }

    #[test]
    fn metrics_bind_parses_override() {
        assert_eq!(
            metrics_bind(Some("0.0.0.0:9100".into())).unwrap(),
            "0.0.0.0:9100".parse::<SocketAddr>().unwrap()
        );
    }

    #[test]
    fn metrics_bind_rejects_garbage_with_var_name_in_error() {
        let err = metrics_bind(Some("not-an-addr".into())).unwrap_err();
        assert!(err.contains("RUTSTER_METRICS_BIND"));
    }

    #[test]
    fn port_range_parses_inclusive_pair() {
        assert_eq!(parse_port_range("10000-20000").unwrap(), (10000, 20000));
    }

    #[test]
    fn port_range_rejects_inverted_and_garbage() {
        assert!(parse_port_range("20000-10000").is_err());
        assert!(parse_port_range("10000").is_err());
        assert!(parse_port_range("a-b").is_err());
    }

    #[test]
    fn media_address_config_defaults_to_loopback_ephemeral() {
        let cfg = media_address_config(None, None, None).unwrap();
        assert!(cfg.bind_ip.is_loopback());
        assert!(cfg.advertised_ip.is_none());
        assert!(cfg.port_range.is_none());
    }

    #[test]
    fn max_sessions_defaults_when_unset() {
        assert_eq!(max_sessions(None).unwrap(), 64);
    }

    #[test]
    fn max_sessions_parses_override() {
        assert_eq!(max_sessions(Some("128".into())).unwrap(), 128);
    }

    #[test]
    fn max_sessions_rejects_garbage_with_var_name_in_error() {
        let err = max_sessions(Some("zero".into())).unwrap_err();
        assert!(err.contains("RUTSTER_MAX_SESSIONS"));
    }

    #[test]
    fn drain_deadline_defaults_to_zero_when_unset() {
        assert_eq!(drain_deadline(None).unwrap(), std::time::Duration::ZERO);
    }

    #[test]
    fn drain_deadline_parses_seconds() {
        assert_eq!(
            drain_deadline(Some("300".into())).unwrap(),
            std::time::Duration::from_secs(300)
        );
    }

    #[test]
    fn drain_deadline_rejects_garbage_with_var_name_in_error() {
        let err = drain_deadline(Some("soon".into())).unwrap_err();
        assert!(err.contains("RUTSTER_DRAIN_DEADLINE_SECS"));
    }

    #[test]
    fn media_address_config_parses_all_three() {
        let cfg = media_address_config(
            Some("0.0.0.0".into()),
            Some("203.0.113.7".into()),
            Some("10000-10100".into()),
        )
        .unwrap();
        assert!(cfg.bind_ip.is_unspecified());
        assert_eq!(cfg.advertised_ip.unwrap().to_string(), "203.0.113.7");
        assert_eq!(cfg.port_range, Some((10000, 10100)));
    }

    #[test]
    fn twilio_credentials_none_when_all_unset() {
        assert!(
            twilio_credentials(None, None, None, None)
                .unwrap()
                .is_none()
        );
    }

    #[test]
    fn twilio_credentials_parses_all_four() {
        let creds = twilio_credentials(
            Some("AC_test_sid".into()),
            Some("secret-token".into()),
            Some("0.0.0.0:8081".into()),
            Some("https://example.com".into()),
        )
        .unwrap()
        .expect("all-four-present should yield Some");
        assert_eq!(creds.account_sid, "AC_test_sid");
        assert_eq!(
            creds.media_streams_bind,
            "0.0.0.0:8081".parse::<SocketAddr>().unwrap()
        );
        assert_eq!(creds.webhook_base.as_str(), "https://example.com/");
    }

    #[test]
    fn twilio_credentials_error_on_partial_config_names_missing_vars() {
        // Only MEDIA_BIND set — the error must name what WAS set so the operator
        // knows which subset to complete or clear.
        let err = twilio_credentials(None, None, Some("0.0.0.0:8081".into()), None).unwrap_err();
        assert!(err.contains("partial"), "msg: {err}");
        assert!(err.contains("RUTSTER_TWILIO_MEDIA_BIND"));
    }

    #[test]
    fn twilio_credentials_error_on_malformed_media_bind() {
        let err = twilio_credentials(
            Some("AC_x".into()),
            Some("tok".into()),
            Some("not-a-socket-addr".into()),
            Some("https://example.com".into()),
        )
        .unwrap_err();
        assert!(err.contains("RUTSTER_TWILIO_MEDIA_BIND"));
    }

    #[test]
    fn twilio_credentials_error_on_malformed_webhook_base() {
        let err = twilio_credentials(
            Some("AC_x".into()),
            Some("tok".into()),
            Some("0.0.0.0:8081".into()),
            Some("not-a-url".into()),
        )
        .unwrap_err();
        assert!(err.contains("RUTSTER_TWILIO_WEBHOOK_BASE"));
    }

    #[test]
    fn ws_ping_interval_defaults_to_20s_when_unset() {
        assert_eq!(
            ws_ping_interval(None).unwrap(),
            std::time::Duration::from_secs(20)
        );
    }

    #[test]
    fn ws_ping_interval_parses_override() {
        assert_eq!(
            ws_ping_interval(Some("5".into())).unwrap(),
            std::time::Duration::from_secs(5)
        );
    }

    #[test]
    fn ws_ping_interval_rejects_zero() {
        let err = ws_ping_interval(Some("0".into())).unwrap_err();
        assert!(err.contains("RUTSTER_WS_PING_SECS"));
    }

    #[test]
    fn ws_ping_interval_rejects_garbage_with_var_name_in_error() {
        let err = ws_ping_interval(Some("often".into())).unwrap_err();
        assert!(err.contains("RUTSTER_WS_PING_SECS"));
    }

    #[test]
    fn trusted_proxies_empty_when_unset() {
        assert!(trusted_proxies(None).unwrap().is_empty());
    }

    #[test]
    fn trusted_proxies_empty_string_is_empty_list() {
        assert!(trusted_proxies(Some("".into())).unwrap().is_empty());
    }

    #[test]
    fn trusted_proxies_parses_cidrs_and_bare_ips() {
        let nets = trusted_proxies(Some("10.0.0.0/24, 192.168.1.7".into())).unwrap();
        assert_eq!(nets.len(), 2);
        assert!(nets[0].contains(&"10.0.0.99".parse::<std::net::IpAddr>().unwrap()));
        // Bare IP == host-length prefix (/32).
        assert!(nets[1].contains(&"192.168.1.7".parse::<std::net::IpAddr>().unwrap()));
        assert!(!nets[1].contains(&"192.168.1.8".parse::<std::net::IpAddr>().unwrap()));
    }

    #[test]
    fn trusted_proxies_rejects_garbage_with_var_name_in_error() {
        let err = trusted_proxies(Some("10.0.0.0/24,not-a-cidr".into())).unwrap_err();
        assert!(err.contains("RUTSTER_TRUSTED_PROXIES"));
        assert!(err.contains("not-a-cidr"));
    }

    #[test]
    fn tls_cert_path_none_when_unset() {
        assert!(tls_cert_path(None).unwrap().is_none());
    }

    #[test]
    fn tls_cert_path_some_when_file_exists() {
        let path = std::env::temp_dir().join("rutster_tls_cert_path_test.txt");
        std::fs::write(&path, b"test cert").unwrap();
        let result = tls_cert_path(Some(path.to_str().unwrap().to_string())).unwrap();
        assert_eq!(result, Some(path.clone()));
        let _ = std::fs::remove_file(&path);
    }

    #[test]
    fn tls_cert_path_rejects_missing_file_with_var_name_in_error() {
        let err = tls_cert_path(Some("/nonexistent/cert.pem".into())).unwrap_err();
        assert!(err.contains("RUTSTER_TLS_CERT"));
    }

    #[test]
    fn tls_key_path_none_when_unset() {
        assert!(tls_key_path(None).unwrap().is_none());
    }

    #[test]
    fn tls_key_path_rejects_missing_file_with_var_name_in_error() {
        let err = tls_key_path(Some("/nonexistent/key.pem".into())).unwrap_err();
        assert!(err.contains("RUTSTER_TLS_KEY"));
    }

    #[test]
    fn valkey_url_none_when_unset() {
        assert!(valkey_url(None).unwrap().is_none());
    }

    #[test]
    fn valkey_url_parses_redis_scheme() {
        let url = valkey_url(Some("redis://127.0.0.1:6379/".into())).unwrap();
        assert_eq!(url.unwrap().as_str(), "redis://127.0.0.1:6379/");
    }

    #[test]
    fn valkey_url_rejects_non_redis_scheme_with_var_name_in_error() {
        let err = valkey_url(Some("https://example.com".into())).unwrap_err();
        assert!(err.contains("RUTSTER_VALKEY_URL"));
        assert!(err.contains("redis://"));
    }

    #[test]
    fn valkey_url_rejects_rediss_with_uncompiled_tls_pointer() {
        // T4: rediss:// is rejected at boot because the redis crate's TLS
        // feature is not compiled in (spec §3.4 staging; rustls Phase 2).
        // The error must point the operator at the uncompiled feature +
        // the spec section, not just "scheme must be redis://".
        let err = valkey_url(Some("rediss://127.0.0.1:6379/".into())).unwrap_err();
        assert!(err.contains("RUTSTER_VALKEY_URL"));
        assert!(err.contains("rediss://"));
        assert!(err.contains("not compiled"));
        assert!(err.contains("§3.4"));
    }

    #[test]
    fn webhook_base_rejects_schemeless_url() {
        // T4: `Url::parse("pbx.example.com:8080")` succeeds with scheme
        // "pbx.example.com" — a schemeless operator input that would 500
        // every webhook at runtime. Boot-time scheme check fails fast.
        let err = twilio_credentials(
            Some("AC_x".into()),
            Some("tok".into()),
            Some("0.0.0.0:8081".into()),
            Some("pbx.example.com:8080".into()),
        )
        .unwrap_err();
        assert!(err.contains("RUTSTER_TWILIO_WEBHOOK_BASE"));
        assert!(err.contains("http"));
        assert!(err.contains("pbx.example.com"));
    }

    #[test]
    fn webhook_base_rejects_xml_special_chars() {
        // T4: the webhook base is interpolated into the TwiML body without
        // an XML-escape pass. The `url` crate percent-encodes `"`, `<`, `>`
        // before they reach `as_str()`, but `&` survives literally as a
        // path char (e.g. `&amp` parses to `/&amp`); the boot-time guard
        // must reject that literal `&` to keep the TwiML body well-formed.
        let err = twilio_credentials(
            Some("AC_x".into()),
            Some("tok".into()),
            Some("0.0.0.0:8081".into()),
            Some("https://example.com/&amp".into()),
        )
        .expect_err("expected error for 'https://example.com/&amp'");
        assert!(err.contains("RUTSTER_TWILIO_WEBHOOK_BASE"), "msg: {err}");
        assert!(err.contains('&'), "msg: {err}");
        assert!(
            err.contains("XML-special") || err.contains("ampersand"),
            "msg: {err}"
        );
    }
}
