//! Combined WebSocket stream for Binance market data (Issue #279)
//!
//! Uses Binance's combined stream endpoint to multiplex multiple symbols
//! over a single WebSocket connection, then demuxes to per-symbol channels.
//!
//! Combined streams wrap each message in an envelope:
//! `{"stream":"btcusdt@aggTrade","data":{...}}`
//!
//! Empirically validated (2026-03-16): 2x9 hybrid delivers 138.7 trades/s
//! with zero recv timeouts vs per-symbol connections which suffered timeouts
//! on low-volume symbols.

use backon::{BackoffBuilder, ExponentialBuilder};
use futures_util::{SinkExt, StreamExt};
use opendeviationbar_core::AggTrade;
use serde::Deserialize;
use std::collections::HashMap;
use tokio::sync::{mpsc, oneshot};
use tokio::time::Duration;
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tokio_util::sync::CancellationToken;

use super::websocket::{BinanceAggTrade, ReconnectionPolicy, WebSocketError};

/// Proactive reconnection interval in seconds (23 hours).
///
/// Binance enforces a 24h maximum WebSocket connection lifetime. At 23h we
/// proactively open an overlapping replacement connection so the switchover
/// happens under our control — not via a server-side disconnect that would
/// create a trade gap. The processor's trade-ID monotonicity guard handles
/// any duplicate trades during the brief overlap window.
pub const PROACTIVE_RECONNECT_SECS: u64 = 23 * 3600;

/// Combined stream envelope format.
///
/// Binance combined streams wrap each message in `{"stream":"...","data":{...}}`.
/// The `stream` field identifies which symbol the trade belongs to (e.g. `"btcusdt@aggTrade"`).
#[derive(Debug, Deserialize)]
struct CombinedEnvelope {
    stream: String,
    data: BinanceAggTrade,
}

/// Build the combined WebSocket URL for multiple symbols.
///
/// Format: `wss://stream.binance.com:9443/stream?streams=sym1@aggTrade/sym2@aggTrade/...`
///
/// Binance limit: 1024 streams per connection. For 18 symbols this is well within limits.
fn build_combined_url(symbols: &[String]) -> String {
    let streams: Vec<String> = symbols
        .iter()
        .map(|s| format!("{}@aggTrade", s.to_lowercase()))
        .collect();
    format!(
        "wss://stream.binance.com:9443/stream?streams={}",
        streams.join("/")
    )
}

/// Extract the uppercase symbol from a combined stream field.
///
/// e.g. `"btcusdt@aggTrade"` -> `"BTCUSDT"`
fn extract_symbol(stream_field: &str) -> Option<String> {
    stream_field.split('@').next().map(|s| s.to_uppercase())
}

/// Parse Binance error responses for ban duration.
/// Returns ban expiry timestamp (ms) if detected.
fn parse_ban_duration(error_msg: &str) -> Option<u64> {
    // Look for "IP banned until <timestamp>" or "banned until <timestamp>" pattern
    if let Some(idx) = error_msg.find("banned until ") {
        let after = &error_msg[idx + "banned until ".len()..];
        // Extract digits
        let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
        digits.parse::<u64>().ok()
    } else {
        None
    }
}

/// Rate limiter for WebSocket control messages (subscribe/unsubscribe).
/// Binance enforces max 5 control messages/sec including pings.
/// Available for future use when dynamic subscription management is added.
#[allow(dead_code)]
pub(crate) struct WsControlRateLimiter {
    /// Minimum interval between control messages
    min_interval: Duration,
    /// Last message send time
    last_send: Option<std::time::Instant>,
}

#[allow(dead_code)]
impl WsControlRateLimiter {
    pub fn new(max_msgs_per_sec: u32, reserved_for_pings: u32) -> Self {
        let effective = max_msgs_per_sec.saturating_sub(reserved_for_pings).max(1);
        Self {
            min_interval: Duration::from_millis(1000 / effective as u64),
            last_send: None,
        }
    }

    /// Wait until the next control message can be sent.
    pub async fn acquire(&mut self) {
        if let Some(last) = self.last_send {
            let elapsed = last.elapsed();
            if elapsed < self.min_interval {
                tokio::time::sleep(self.min_interval - elapsed).await;
            }
        }
        self.last_send = Some(std::time::Instant::now());
    }

    /// Get the minimum interval between messages (for testing).
    #[cfg(test)]
    pub fn min_interval(&self) -> Duration {
        self.min_interval
    }
}

/// Combined WebSocket stream for Binance aggTrade data (Issue #279).
///
/// Connects to `wss://stream.binance.com:9443/stream?streams=...` with multiple
/// symbols multiplexed on a single connection. Demuxes incoming trades to
/// per-symbol `mpsc::Sender<AggTrade>` channels so that downstream `symbol_task()`
/// consumers see no interface change.
///
/// This replaces per-symbol `BinanceWebSocketStream` connections which suffered
/// recv timeouts on low-volume symbols and were susceptible to rate limiting.
#[derive(Debug)]
pub struct CombinedWebSocketStream {
    symbols: Vec<String>,
}

impl CombinedWebSocketStream {
    /// Create a new combined stream for the given symbols.
    pub fn new(symbols: Vec<String>) -> Self {
        Self { symbols }
    }

    /// Get the symbols this stream handles.
    pub fn symbols(&self) -> &[String] {
        &self.symbols
    }

    /// Connect to the combined stream and demux trades to per-symbol senders.
    ///
    /// Core loop mirrors `BinanceWebSocketStream::connect_and_stream()` but:
    /// - Parses `CombinedEnvelope` instead of raw `BinanceAggTrade`
    /// - Extracts symbol from `envelope.stream` and routes to correct sender
    /// - Unknown symbols are logged and skipped (handles delistings gracefully)
    /// - Client-side ping with real pong deadline tracking
    /// - Stream signal emission via tracing
    /// - Ban/rate-limit detection on close frames
    ///
    /// `first_trade_signal`: If `Some`, fires once after the first trade is
    /// successfully sent to a per-symbol channel. Used by overlapping
    /// reconnection to confirm the new connection is live before closing the old.
    pub async fn connect_and_demux(
        symbols: &[String],
        senders: &HashMap<String, mpsc::Sender<AggTrade>>,
        shutdown: &CancellationToken,
        data_timeout: Duration,
        first_trade_signal: Option<oneshot::Sender<()>>,
        ping_interval: Duration,
        pong_timeout: Duration,
    ) -> Result<(), WebSocketError> {
        let url = build_combined_url(symbols);
        let symbol_count = symbols.len();

        tracing::info!(
            %url,
            symbol_count,
            "connecting to Binance combined WebSocket"
        );

        let (ws_stream, _) = connect_async(&url).await?;
        let (mut ws_sender, mut ws_reader) = ws_stream.split();

        tracing::info!(
            signal = "CONNECT",
            symbol_count,
            "combined WebSocket connected -- stream signal"
        );

        // Fires once after the first trade is delivered, signaling the caller
        // that this connection is live. Used by overlapping reconnection.
        let mut first_trade_signal = first_trade_signal;
        let mut first_data_signalled = false;

        // Client-side ping with pong deadline tracking
        let mut ping_ticker = tokio::time::interval(ping_interval);
        ping_ticker.tick().await; // consume first immediate tick
        let mut last_ping_sent: Option<std::time::Instant> = None;

        loop {
            // Check pong deadline before entering select
            if let Some(sent_at) = last_ping_sent
                && sent_at.elapsed() > pong_timeout
            {
                tracing::warn!(
                    pong_timeout_ms = pong_timeout.as_millis(),
                    "pong timeout -- no response to ping within deadline, reconnecting"
                );
                tracing::info!(
                    signal = "DISCONNECT",
                    symbol_count,
                    "combined WebSocket disconnected -- stream signal"
                );
                return Err(WebSocketError::PongTimeout);
            }

            tokio::select! {
                msg = ws_reader.next() => {
                    match msg {
                        Some(Ok(Message::Text(text))) => {
                            match serde_json::from_str::<CombinedEnvelope>(&text) {
                                Ok(envelope) => {
                                    let symbol = match extract_symbol(&envelope.stream) {
                                        Some(s) => s,
                                        None => {
                                            tracing::warn!(
                                                stream = %envelope.stream,
                                                "failed to extract symbol from stream field"
                                            );
                                            continue;
                                        }
                                    };

                                    match senders.get(&symbol) {
                                        Some(sender) => {
                                            let agg_trade = envelope.data.to_agg_trade();
                                            if sender.send(agg_trade).await.is_err() {
                                                tracing::warn!(
                                                    %symbol,
                                                    "trade channel closed for symbol"
                                                );
                                                // Don't return ChannelSendFailed for one symbol —
                                                // other symbols may still be active. Log and continue.
                                                continue;
                                            }
                                            // Signal that this connection is live (first trade delivered)
                                            if let Some(sig) = first_trade_signal.take() {
                                                let _ = sig.send(());
                                            }
                                            // Emit FIRST_DATA signal once
                                            if !first_data_signalled {
                                                tracing::info!(
                                                    signal = "FIRST_DATA",
                                                    symbol_count,
                                                    "combined WebSocket first trade delivered -- stream signal"
                                                );
                                                first_data_signalled = true;
                                            }
                                        }
                                        None => {
                                            tracing::warn!(
                                                %symbol,
                                                stream = %envelope.stream,
                                                "unknown symbol in combined stream, skipping"
                                            );
                                        }
                                    }
                                }
                                Err(e) => {
                                    tracing::warn!(?e, "combined stream JSON parse failed");
                                }
                            }
                        }
                        Some(Ok(Message::Ping(data))) => {
                            if let Err(e) = ws_sender.send(Message::Pong(data)).await {
                                tracing::warn!(%e, "combined stream pong send failed");
                                return Err(WebSocketError::PongSendFailed(e.to_string()));
                            }
                        }
                        Some(Ok(Message::Pong(_))) => {
                            last_ping_sent = None;
                            tracing::trace!("combined stream pong received");
                        }
                        Some(Ok(Message::Close(frame))) => {
                            let reason = frame.as_ref().map(|f| f.reason.to_string()).unwrap_or_default();

                            tracing::info!(
                                signal = "DISCONNECT",
                                symbol_count,
                                ?frame,
                                "combined WebSocket closed by server -- stream signal"
                            );

                            // Check for rate limit / ban in close frame reason
                            if reason.contains("429") || reason.contains("Too many requests") {
                                if let Some(ban_until) = parse_ban_duration(&reason) {
                                    return Err(WebSocketError::BanDetected {
                                        ban_until_ms: ban_until,
                                        reason,
                                    });
                                }
                                return Err(WebSocketError::RateLimited {
                                    retry_after_ms: None,
                                });
                            }

                            return Err(WebSocketError::ConnectionClosed);
                        }
                        Some(Ok(_)) => {
                            // Binary — ignore
                        }
                        Some(Err(e)) => {
                            tracing::warn!(%e, "combined WebSocket error");
                            tracing::info!(
                                signal = "DISCONNECT",
                                symbol_count,
                                "combined WebSocket disconnected -- stream signal"
                            );
                            return Err(WebSocketError::from(e));
                        }
                        None => {
                            tracing::info!(
                                signal = "DISCONNECT",
                                symbol_count,
                                "combined WebSocket stream ended -- stream signal"
                            );
                            return Err(WebSocketError::ConnectionClosed);
                        }
                    }
                }
                _ = ping_ticker.tick() => {
                    if let Err(e) = ws_sender.send(Message::Ping(vec![])).await {
                        tracing::warn!(%e, "combined stream client-side ping failed");
                        return Err(WebSocketError::PongSendFailed(e.to_string()));
                    }
                    last_ping_sent = Some(std::time::Instant::now());
                    tracing::trace!("combined stream client-side ping sent");
                }
                () = tokio::time::sleep(data_timeout) => {
                    tracing::warn!(
                        timeout_secs = data_timeout.as_secs(),
                        "combined stream data timeout — no WebSocket frames, treating as dead"
                    );
                    tracing::info!(
                        signal = "DISCONNECT",
                        symbol_count,
                        "combined WebSocket disconnected -- stream signal"
                    );
                    return Err(WebSocketError::ConnectionClosed);
                }
                () = shutdown.cancelled() => {
                    tracing::info!(
                        signal = "DISCONNECT",
                        symbol_count,
                        "shutdown requested, closing combined WebSocket -- stream signal"
                    );
                    return Ok(());
                }
            }
        }
    }

    /// Build a `backon` exponential backoff iterator from reconnection policy.
    ///
    /// Antaeus: extracted so we can recreate the iterator (reset backoff)
    /// after a stable connection, like the giant regaining strength from the earth.
    fn build_backoff(policy: &ReconnectionPolicy) -> backon::ExponentialBackoff {
        ExponentialBuilder::default()
            .with_min_delay(Duration::from_millis(policy.backoff_ms_initial))
            .with_max_delay(Duration::from_millis(policy.backoff_ms_max))
            .with_factor(policy.backoff_multiplier as f32)
            .with_jitter()
            .build()
    }

    /// Run combined WebSocket with auto-reconnection and exponential backoff.
    ///
    /// Antaeus reconnection pattern: backoff resets after stable connections
    /// (>= `stable_connection_ms`). Jitter via `backon` prevents thundering herd.
    ///
    /// **Proactive reconnection (COMB-05):** At [`PROACTIVE_RECONNECT_SECS`] (23h),
    /// a new overlapping connection is spawned. Once the new connection delivers
    /// its first trade (confirmed via oneshot signal), the old connection is
    /// cancelled. The processor's trade-ID monotonicity guard silently drops
    /// any duplicate trades during the brief overlap window. This eliminates
    /// the gap that would otherwise occur when Binance force-disconnects at 24h.
    ///
    /// **Ban handling:** `BanDetected` errors are intercepted BEFORE the terminal
    /// check. The sidecar sleeps until the ban expires, then resumes. This prevents
    /// the terminal classification from causing a permanent exit.
    ///
    /// Same terminal vs recoverable error classification as per-symbol streams.
    pub async fn run_with_reconnect(
        symbols: Vec<String>,
        senders: HashMap<String, mpsc::Sender<AggTrade>>,
        policy: ReconnectionPolicy,
        shutdown: CancellationToken,
    ) {
        let stable_threshold = Duration::from_millis(policy.stable_connection_ms);
        let data_timeout = Duration::from_secs(policy.data_timeout_secs);
        let ping_interval = Duration::from_secs(policy.ping_interval_secs);
        let pong_timeout = Duration::from_secs(policy.pong_timeout_secs);
        let proactive_duration = Duration::from_secs(PROACTIVE_RECONNECT_SECS);
        let mut backoff = Self::build_backoff(&policy);

        loop {
            let connection_start = std::time::Instant::now();

            // Child token so we can cancel just this connection during proactive reconnect
            let conn_shutdown = shutdown.child_token();

            let result = tokio::select! {
                res = Self::connect_and_demux(
                    &symbols, &senders, &conn_shutdown, data_timeout, None,
                    ping_interval, pong_timeout,
                ) => res,
                () = tokio::time::sleep(proactive_duration) => {
                    // 23h timer fired — initiate overlapping reconnection
                    tracing::info!(
                        elapsed_secs = connection_start.elapsed().as_secs(),
                        symbol_count = symbols.len(),
                        "proactive reconnect timer fired — spawning overlapping connection"
                    );

                    let (tx, rx) = oneshot::channel::<()>();
                    let new_symbols = symbols.clone();
                    let new_senders = senders.clone();
                    let new_shutdown = shutdown.child_token();
                    let new_data_timeout = data_timeout;
                    let new_ping_interval = ping_interval;
                    let new_pong_timeout = pong_timeout;

                    // Spawn the new connection — it sends to the same channels
                    let new_conn = tokio::spawn(async move {
                        Self::connect_and_demux(
                            &new_symbols,
                            &new_senders,
                            &new_shutdown,
                            new_data_timeout,
                            Some(tx),
                            new_ping_interval,
                            new_pong_timeout,
                        ).await
                    });

                    // Wait for the new connection to deliver its first trade,
                    // or timeout after 30s (e.g. connect failure)
                    let handoff_ok = tokio::select! {
                        Ok(()) = rx => {
                            tracing::info!(
                                "overlapping connection delivered first trade — cancelling old"
                            );
                            true
                        }
                        () = tokio::time::sleep(Duration::from_secs(30)) => {
                            tracing::warn!(
                                "overlapping connection did not deliver first trade within 30s"
                            );
                            false
                        }
                        () = shutdown.cancelled() => {
                            tracing::info!("shutdown during overlapping reconnection handoff");
                            conn_shutdown.cancel();
                            return;
                        }
                    };

                    // Cancel the old connection regardless
                    conn_shutdown.cancel();

                    if handoff_ok {
                        // New connection is live — wait for it to finish (will be the
                        // "current" connection; when it eventually disconnects or the
                        // next iteration's 23h timer fires, the outer loop handles it).
                        match new_conn.await {
                            Ok(Ok(())) => {
                                // Clean shutdown via CancellationToken on the new connection
                                tracing::info!("overlapping connection clean shutdown");
                                return;
                            }
                            Ok(Err(e)) if e.is_terminal() => {
                                tracing::error!(
                                    ?e,
                                    "overlapping connection terminal error"
                                );
                                return;
                            }
                            Ok(Err(e)) => {
                                // Recoverable — fall through to backoff + reconnect
                                tracing::warn!(
                                    ?e,
                                    "overlapping connection disconnected"
                                );
                                Err(e)
                            }
                            Err(join_err) => {
                                tracing::error!(
                                    %join_err,
                                    "overlapping connection task panicked"
                                );
                                Err(WebSocketError::ConnectionClosed)
                            }
                        }
                    } else {
                        // Handoff failed — abort new connection, fall through to
                        // normal reconnect (old connection is already cancelled)
                        new_conn.abort();
                        Err(WebSocketError::ConnectionClosed)
                    }
                }
                () = shutdown.cancelled() => {
                    conn_shutdown.cancel();
                    tracing::info!(
                        symbol_count = symbols.len(),
                        "combined stream clean shutdown (outer)"
                    );
                    return;
                }
            };

            match result {
                Ok(()) => {
                    // Clean shutdown requested via CancellationToken
                    tracing::info!(
                        symbol_count = symbols.len(),
                        "combined stream clean shutdown"
                    );
                    break;
                }
                // Ban handling: sleep until ban expires, then resume.
                // Must be checked BEFORE is_terminal() since BanDetected is terminal
                // to prevent lower-level code from blindly retrying, but at the
                // run_with_reconnect level we handle it specially.
                Err(WebSocketError::BanDetected {
                    ban_until_ms,
                    ref reason,
                }) => {
                    let now_ms = std::time::SystemTime::now()
                        .duration_since(std::time::UNIX_EPOCH)
                        .unwrap()
                        .as_millis() as u64;
                    let sleep_ms = ban_until_ms.saturating_sub(now_ms);
                    tracing::error!(
                        ban_until_ms,
                        sleep_secs = sleep_ms / 1000,
                        %reason,
                        "IP banned by Binance -- sleeping until ban expires"
                    );
                    if sleep_ms > 0 {
                        tokio::select! {
                            () = tokio::time::sleep(Duration::from_millis(sleep_ms)) => {
                                tracing::info!("ban sleep complete, resuming");
                                backoff = Self::build_backoff(&policy);
                                continue;
                            }
                            () = shutdown.cancelled() => {
                                tracing::info!("shutdown during ban sleep");
                                break;
                            }
                        }
                    }
                    // If ban already expired, just continue
                    backoff = Self::build_backoff(&policy);
                    continue;
                }
                Err(WebSocketError::PongTimeout) => {
                    tracing::warn!("pong timeout -- reconnecting");
                    // PongTimeout is not terminal — falls through to normal reconnect
                    if connection_start.elapsed() >= stable_threshold {
                        backoff = Self::build_backoff(&policy);
                    }

                    let delay = match backoff.next() {
                        Some(d) => d,
                        None => break,
                    };

                    tracing::warn!(
                        ?delay,
                        symbol_count = symbols.len(),
                        "pong timeout — reconnecting after backoff"
                    );

                    tokio::select! {
                        () = tokio::time::sleep(delay) => {}
                        () = shutdown.cancelled() => {
                            tracing::info!("shutdown during pong timeout backoff");
                            break;
                        }
                    }
                }
                Err(e) if e.is_terminal() => {
                    tracing::error!(
                        ?e,
                        symbol_count = symbols.len(),
                        "terminal combined WebSocket error — not retrying"
                    );
                    break;
                }
                Err(e) => {
                    // Antaeus: reset backoff if connection was stable
                    if connection_start.elapsed() >= stable_threshold {
                        backoff = Self::build_backoff(&policy);
                    }

                    let delay = match backoff.next() {
                        Some(d) => d,
                        None => break, // Max retries exhausted (if configured)
                    };

                    tracing::warn!(
                        ?e,
                        ?delay,
                        symbol_count = symbols.len(),
                        "combined WebSocket disconnected, reconnecting"
                    );

                    tokio::select! {
                        () = tokio::time::sleep(delay) => {}
                        () = shutdown.cancelled() => {
                            tracing::info!("shutdown during combined stream backoff");
                            break;
                        }
                    }
                }
            }
        }
    }
}

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

    #[test]
    fn test_combined_envelope_parsing() {
        let json = r#"{"stream":"btcusdt@aggTrade","data":{"e":"aggTrade","E":1758666334424,"s":"BTCUSDT","a":3679713739,"p":"112070.01000000","q":"0.01328000","f":5252203256,"l":5252203266,"T":1758666334424,"m":false,"M":true}}"#;
        let envelope: CombinedEnvelope = serde_json::from_str(json).unwrap();
        assert_eq!(envelope.stream, "btcusdt@aggTrade");
        let trade = envelope.data.to_agg_trade();
        assert_eq!(trade.agg_trade_id, 3679713739);
        assert_eq!(trade.price.0, 11207001000000);
    }

    #[test]
    fn test_combined_url_construction() {
        let symbols = vec!["BTCUSDT".into(), "ETHUSDT".into(), "SOLUSDT".into()];
        let url = build_combined_url(&symbols);
        assert_eq!(
            url,
            "wss://stream.binance.com:9443/stream?streams=btcusdt@aggTrade/ethusdt@aggTrade/solusdt@aggTrade"
        );
    }

    #[test]
    fn test_envelope_unknown_symbol_skipped() {
        let stream_field = "xyzusdt@aggTrade";
        let symbol = extract_symbol(stream_field).unwrap();
        assert_eq!(symbol, "XYZUSDT");
    }

    #[test]
    fn test_combined_url_18_symbols() {
        let symbols: Vec<String> = [
            "BTCUSDT",
            "ETHUSDT",
            "SOLUSDT",
            "ADAUSDT",
            "XRPUSDT",
            "DOGEUSDT",
            "AVAXUSDT",
            "DOTUSDT",
            "LINKUSDT",
            "UNIUSDT",
            "ATOMUSDT",
            "NEARUSDT",
            "FILUSDT",
            "LTCUSDT",
            "MATICUSDT",
            "SHIBUSDT",
            "TRXUSDT",
            "WIFUSDT",
        ]
        .iter()
        .map(|s| s.to_string())
        .collect();
        let url = build_combined_url(&symbols);
        assert!(url.starts_with("wss://stream.binance.com:9443/stream?streams="));
        assert_eq!(url.matches("@aggTrade").count(), 18);
    }

    #[test]
    fn test_extract_symbol_valid() {
        assert_eq!(extract_symbol("btcusdt@aggTrade"), Some("BTCUSDT".into()));
        assert_eq!(extract_symbol("ethusdt@aggTrade"), Some("ETHUSDT".into()));
    }

    #[test]
    fn test_extract_symbol_empty() {
        // Edge case: empty string
        assert_eq!(extract_symbol(""), Some("".into()));
        // No @ separator — returns the whole string uppercased
        assert_eq!(extract_symbol("btcusdt"), Some("BTCUSDT".into()));
    }

    #[test]
    fn test_combined_stream_new() {
        let symbols = vec!["BTCUSDT".into(), "ETHUSDT".into()];
        let stream = CombinedWebSocketStream::new(symbols.clone());
        assert_eq!(stream.symbols(), &symbols);
    }

    #[test]
    fn test_proactive_reconnect_constant() {
        // Exactly 23 hours in seconds
        assert_eq!(PROACTIVE_RECONNECT_SECS, 82800);
        // Safety: must be >22h and <24h to stay ahead of Binance's 24h limit
        assert!(PROACTIVE_RECONNECT_SECS > 22 * 3600);
        assert!(PROACTIVE_RECONNECT_SECS < 24 * 3600);
    }

    #[tokio::test]
    async fn test_proactive_reconnect_oneshot_signal() {
        // Verify oneshot channel mechanics used in overlapping reconnection:
        // sender fires once, receiver gets the signal.
        let (tx, rx) = oneshot::channel::<()>();
        tx.send(()).unwrap();
        assert!(rx.await.is_ok());
    }

    #[test]
    fn test_parse_ban_duration() {
        let msg = "Too many requests; IP banned until 1700000000000.";
        assert_eq!(parse_ban_duration(msg), Some(1700000000000));
    }

    #[test]
    fn test_parse_ban_duration_no_ban() {
        let msg = "Connection closed normally";
        assert_eq!(parse_ban_duration(msg), None);
    }

    #[test]
    fn test_ws_control_rate_limiter_spacing() {
        // 5 total - 2 reserved for pings = 3 effective msg/sec
        // min_interval = 1000 / 3 = 333ms
        let limiter = WsControlRateLimiter::new(5, 2);
        assert_eq!(limiter.min_interval(), Duration::from_millis(333));
    }
}
