//! # TwilioCallControlClient — the live Twilio REST call-control client (green zone)
//!
//! This module is **feature-gated behind `twilio-live`** — it is NOT compiled in
//! the default CI build (which uses [`MockCallControlClient`](super::MockCallControlClient)).
//! The maintainer enables it with `cargo test --features=twilio-live` when validating
//! a release against real Twilio credentials.
//!
//! ## Green zone (ADR-0008)
//!
//! This client is arm's-length from the FOB hot path: it fires only on
//! originate (outbound call) + hangup (teardown). It is NOT on the 20 ms media
//! tick. The brain never holds a reference to it; the brain never sees the
//! [`TwilioCredentials`] (ADR-0009). Only the binary's route handlers (T5) construct
//! + call this client.
//!
//! ## What it talks to
//!
//! Twilio's REST API (<https://api.twilio.com/2010-04-01/Accounts/{sid}/Calls.json>)
//! over HTTPS with HTTP basic auth (`account_sid:auth_token`). The `originate` call
//! POSTs a form body containing `To`, `From`, + a `Twiml` parameter whose value is a
//! `<Response><Connect><Stream url="…"/></Connect></Response>` document instructing
//! Twilio to open a Media Streams WebSocket fork against our `/twilio/media-stream`
//! endpoint. `hangup` POSTs `Status=completed` to the per-call resource.
//!
//! ## Credential isolation (ADR-0009 — load-bearing)
//!
//! `auth_token` is used ONLY for the HTTP basic-auth header on the REST call. It is
//! **never** logged: `tracing::debug!` fields expose only caller-controlled values
//! (`to`, `from`) + the last 4 chars of the `CallSid` (for log correlation). The
//! `TwilioCredentials::Debug` impl (in [`super::TwilioCredentials`]) redacts the
//! token. This is the codebase's standing posture on secrets (cf. `api_key.rs`,
//! slice-3).
//!
//! ## Why `reqwest` (not `hyper` direct or hand-rolled)
//!
//! `reqwest` is the de-facto maintained Rust HTTP client; it wraps `hyper` + a TLS
//! backend. We pin `rustls-tls` (not native-tls/OpenSSL) at the workspace level to
//! keep the TLS stack pure-Rust, consistent with `str0m`'s existing `aws-lc-rs`
//! crypto provider (ADR-0001 "memory-safe by construction"). The client is optional
//! (gated by `dep:reqwest`) so the default CI resolve stays lean.

use async_trait::async_trait;

use super::{CallControlClient, CallControlError, TwilioCredentials};

/// Live Twilio call-control client. REST against Twilio's Calls API.
///
/// Construct with [`TwilioCallControlClient::new`] (passing a [`TwilioCredentials`]
/// built by `config::twilio_credentials` in the binary crate). The `reqwest::Client`
/// is reused across calls (connection pooling); construct once at startup, not per-call.
pub struct TwilioCallControlClient {
    credentials: TwilioCredentials,
    http: reqwest::Client,
}

impl TwilioCallControlClient {
    /// Construct a live client. The `reqwest::Client` is created with default options
    /// (rustls-tls backend, connection pool). The caller passes the credentials built
    /// from env by the binary's `config::twilio_credentials` parser.
    pub fn new(credentials: TwilioCredentials) -> Self {
        Self {
            credentials,
            http: reqwest::Client::new(),
        }
    }

    /// Derive the public Media Streams WSS endpoint URL the TwiML `<Stream>` directive
    /// points Twilio at, from the operator's `webhook_base`. Twilio connects BACK to us
    /// over WebSocket to fork the call's audio, so the URL must be the *public* address
    /// (a reverse proxy maps the public `:443` → the local `media_streams_bind`).
    ///
    /// `https` → `wss`, `http` → `ws` (local dev may use plaintext WS per spec §1.2's
    /// TLS-deferral; production uses `wss`). The path `/twilio/media-stream` matches the
    /// route `TwilioMediaStreamsServer::router` (T3) mounts.
    fn stream_url(&self) -> String {
        let scheme = match self.credentials.webhook_base.scheme() {
            "https" => "wss",
            "http" => "ws",
            other => other,
        };
        let host = self
            .credentials
            .webhook_base
            .host_str()
            .unwrap_or("localhost");
        match self.credentials.webhook_base.port() {
            Some(port) => format!("{scheme}://{host}:{port}/twilio/media-stream"),
            None => format!("{scheme}://{host}/twilio/media-stream"),
        }
    }
}

#[async_trait]
impl CallControlClient for TwilioCallControlClient {
    async fn originate(
        &self,
        to_phone: &str,
        from_phone: &str,
        _spend_token: Option<super::SpendToken>,
    ) -> Result<String, CallControlError> {
        // The TwiML instructs Twilio to <Connect> a <Stream> back to our WSS endpoint.
        // Twilio forks the call's audio (µ-law @ 8 kHz) over the WS in real time;
        // TwilioMediaStreamsServer (T3) receives + decodes via G711Codec (T1).
        let twiml = format!(
            "<Response><Connect><Stream url=\"{}\"/></Connect></Response>",
            self.stream_url()
        );
        let url = format!(
            "https://api.twilio.com/2010-04-01/Accounts/{}/Calls.json",
            self.credentials.account_sid
        );

        let response = self
            .http
            .post(&url)
            .basic_auth(
                &self.credentials.account_sid,
                Some(&self.credentials.auth_token),
            )
            .form(&[("To", to_phone), ("From", from_phone), ("Twiml", &twiml)])
            .send()
            .await
            .map_err(|e| CallControlError(format!("twilio originate transport: {e}")))?;

        let status = response.status();
        let body: serde_json::Value = response
            .json()
            .await
            .map_err(|e| CallControlError(format!("twilio originate body parse: {e}")))?;

        if !status.is_success() {
            let msg = body
                .get("message")
                .and_then(|v| v.as_str())
                .unwrap_or("(no message field)");
            return Err(CallControlError(format!(
                "twilio originate rejected ({status}): {msg}"
            )));
        }

        let call_sid = body
            .get("sid")
            .and_then(|v| v.as_str())
            .ok_or_else(|| CallControlError("twilio response missing 'sid' field".into()))?
            .to_owned();

        // Log ONLY caller-controlled fields + the last 4 chars of the CallSid
        // (for log correlation). auth_token is NEVER logged (ADR-0009).
        let last4 = &call_sid[call_sid.len().saturating_sub(4)..];
        tracing::debug!(
            to = to_phone,
            from = from_phone,
            sid_last4 = last4,
            "twilio originate acknowledged"
        );

        Ok(call_sid)
    }

    async fn hangup(&self, correlation_id: &str) -> Result<(), CallControlError> {
        let url = format!(
            "https://api.twilio.com/2010-04-01/Accounts/{}/Calls/{}.json",
            self.credentials.account_sid, correlation_id
        );

        let response = self
            .http
            .post(&url)
            .basic_auth(
                &self.credentials.account_sid,
                Some(&self.credentials.auth_token),
            )
            .form(&[("Status", "completed")])
            .send()
            .await
            .map_err(|e| CallControlError(format!("twilio hangup transport: {e}")))?;

        let status = response.status();
        if !status.is_success() {
            let body = response.text().await.unwrap_or_default();
            return Err(CallControlError(format!(
                "twilio hangup rejected ({status}): {body}"
            )));
        }

        let last4 = &correlation_id[correlation_id.len().saturating_sub(4)..];
        tracing::debug!(sid_last4 = last4, "twilio hangup acknowledged");

        Ok(())
    }
}

#[cfg(all(test, feature = "twilio-live"))]
mod live_tests {
    //! Live e2e tests against real Twilio. `#[ignore]` because they place a real
    //! outbound call (charged against your Twilio account). Run manually:
    //!
    //! ```bash
    //! RUTSTER_TWILIO_ACCOUNT_SID=AC... \
    //! RUTSTER_TWILIO_AUTH_TOKEN=... \
    //! RUTSTER_TWILIO_MEDIA_BIND=0.0.0.0:8081 \
    //! RUTSTER_TWILIO_WEBHOOK_BASE=https://your.public.host \
    //! RUTSTER_TWILIO_TEST_TO=+1555... \
    //! RUTSTER_TWILIO_TEST_FROM=+1555... \
    //! cargo test --features=twilio-live -p rutster-trunk -- --include-ignored live_tests
    //! ```
    //!
    //! These read env vars directly (not via the binary's `config::twilio_credentials`)
    //! because the trunk crate cannot depend on the binary crate. `#[ignore]` tests are
    //! single-run (the maintainer invokes one at a time), so the env-mutation race that
    //! the pure-function `config.rs` pattern guards against does not apply here.

    use super::*;
    use std::net::SocketAddr;

    fn creds_from_env() -> Option<TwilioCredentials> {
        let account_sid = std::env::var("RUTSTER_TWILIO_ACCOUNT_SID").ok()?;
        let auth_token = std::env::var("RUTSTER_TWILIO_AUTH_TOKEN").ok()?;
        let media_streams_bind: SocketAddr = std::env::var("RUTSTER_TWILIO_MEDIA_BIND")
            .ok()?
            .parse()
            .ok()?;
        let webhook_base: url::Url = std::env::var("RUTSTER_TWILIO_WEBHOOK_BASE")
            .ok()?
            .parse()
            .ok()?;
        Some(TwilioCredentials {
            account_sid,
            auth_token,
            media_streams_bind,
            webhook_base,
        })
    }

    #[tokio::test]
    #[ignore = "places a real outbound Twilio call (billed). manual run only."]
    async fn originate_real_call_returns_call_sid() {
        let creds = creds_from_env()
            .expect("set RUTSTER_TWILIO_{ACCOUNT_SID,AUTH_TOKEN,MEDIA_BIND,WEBHOOK_BASE}");
        let client = TwilioCallControlClient::new(creds);
        let to = std::env::var("RUTSTER_TWILIO_TEST_TO").expect("set RUTSTER_TWILIO_TEST_TO");
        let from = std::env::var("RUTSTER_TWILIO_TEST_FROM").expect("set RUTSTER_TWILIO_TEST_FROM");

        let call_sid = client.originate(&to, &from, None).await.expect("originate");
        assert!(
            call_sid.starts_with("CA"),
            "expected CA-prefixed CallSid, got {call_sid}"
        );

        // Tear down immediately so the test number doesn't actually ring.
        client.hangup(&call_sid).await.expect("hangup");
    }

    #[test]
    fn stream_url_derives_wss_from_https_webhook_base() {
        let creds = TwilioCredentials {
            account_sid: "AC_test".into(),
            auth_token: "tok".into(),
            media_streams_bind: "0.0.0.0:8081".parse().unwrap(),
            webhook_base: "https://example.com".parse().unwrap(),
        };
        let client = TwilioCallControlClient::new(creds);
        assert_eq!(client.stream_url(), "wss://example.com/twilio/media-stream");
    }

    #[test]
    fn stream_url_derives_ws_from_http_webhook_base() {
        let creds = TwilioCredentials {
            account_sid: "AC_test".into(),
            auth_token: "tok".into(),
            media_streams_bind: "127.0.0.1:8081".parse().unwrap(),
            webhook_base: "http://localhost:8080".parse().unwrap(),
        };
        let client = TwilioCallControlClient::new(creds);
        assert_eq!(
            client.stream_url(),
            "ws://localhost:8080/twilio/media-stream"
        );
    }
}
