// FILE-SIZE-OK: Tests are inline per Rust convention, splitting would lose #[cfg(test)] gating
//! WebSocket streaming for real-time Binance market data (Issue #91)
//!
//! This module provides asynchronous WebSocket connections to Binance streams
//! for real-time aggTrade data feeding into open deviation bar construction.
//!
//! Production-hardened with:
//! - Exponential backoff reconnection (adapted from barter-rs)
//! - Ping/pong handling (Binance disconnects after 3 missed pongs)
//! - Terminal vs recoverable error classification
//! - Graceful shutdown via `CancellationToken`

use backon::{BackoffBuilder, ExponentialBuilder};
use futures_util::{SinkExt, StreamExt};
use opendeviationbar_core::{AggTrade, FixedPoint, normalize_timestamp};
use serde::Deserialize;

use crate::serde_helpers::deserialize_fixedpoint_from_str;
use std::pin::Pin;
use std::task::{Context, Poll};
use thiserror::Error;
use tokio::sync::mpsc;
use tokio::time::Duration;
use tokio_stream::Stream;
use tokio_tungstenite::{connect_async, tungstenite::Message};
use tokio_util::sync::CancellationToken;

/// WebSocket specific errors
/// Issue #91: Hardened with `is_terminal()` for reconnection routing
#[derive(Error, Debug)]
pub enum WebSocketError {
    #[error("Connection failed: {0}")]
    ConnectionFailed(Box<tokio_tungstenite::tungstenite::Error>),

    #[error("JSON parsing failed: {0}")]
    JsonParsingFailed(#[from] serde_json::Error),

    #[error("Channel send failed")]
    ChannelSendFailed,

    #[error("Invalid symbol: {0}")]
    InvalidSymbol(String),

    #[error("Connection closed unexpectedly")]
    ConnectionClosed,

    #[error("Pong send failed: {0}")]
    PongSendFailed(String),
}

impl WebSocketError {
    /// Whether this error is terminal (no retry) or recoverable (retry with backoff).
    ///
    /// Terminal errors indicate a logic bug or permanent condition that retrying
    /// cannot fix. Recoverable errors are transient network/server issues.
    pub const fn is_terminal(&self) -> bool {
        match self {
            // Terminal: bad input, consumer dropped — retrying won't help
            Self::InvalidSymbol(_) | Self::ChannelSendFailed => true,
            // Recoverable: transient network, server kick, bad message
            Self::ConnectionFailed(_)
            | Self::JsonParsingFailed(_)
            | Self::ConnectionClosed
            | Self::PongSendFailed(_) => false,
        }
    }
}

impl From<tokio_tungstenite::tungstenite::Error> for WebSocketError {
    fn from(error: tokio_tungstenite::tungstenite::Error) -> Self {
        WebSocketError::ConnectionFailed(Box::new(error))
    }
}

/// Reconnection policy with exponential backoff.
/// Issue #91: Adapted from barter-rs `ReconnectingStream` pattern.
/// Issue #107: Added `data_timeout_secs` for half-open TCP detection.
/// Antaeus: Added `stable_connection_ms` for backoff reset after stable connections.
#[derive(Debug, Clone)]
pub struct ReconnectionPolicy {
    /// Initial backoff duration in milliseconds (default: 125ms)
    pub backoff_ms_initial: u64,
    /// Backoff multiplier (default: 2x)
    pub backoff_multiplier: u64,
    /// Maximum backoff duration in milliseconds (default: 60s)
    pub backoff_ms_max: u64,
    /// Max seconds of silence before treating connection as dead (default: 90s).
    /// Binance sends pings every ~180s, BTCUSDT trades every ~50ms.
    /// 90s of total silence is unambiguously dead.
    pub data_timeout_secs: u64,
    /// Antaeus: Connection lasting longer than this resets backoff (default: 60s).
    /// Named for the giant who regained strength by touching the earth —
    /// a stable connection "touching ground" resets backoff to full vigor.
    pub stable_connection_ms: u64,
}

impl Default for ReconnectionPolicy {
    fn default() -> Self {
        Self {
            backoff_ms_initial: 125,
            backoff_multiplier: 2,
            backoff_ms_max: 60_000,
            data_timeout_secs: 90,
            stable_connection_ms: 60_000,
        }
    }
}

/// Binance WebSocket aggTrade message format
/// Issue #96: price/quantity deserialize directly to FixedPoint (zero String allocation).
/// event_type ("e") and symbol ("s") are skipped — redundant (known from WS URL).
/// pub(crate): shared with combined_ws.rs for combined stream envelope parsing.
#[derive(Debug, Deserialize)]
pub(crate) struct BinanceAggTrade {
    #[serde(rename = "e")]
    _event_type: serde::de::IgnoredAny,

    #[serde(rename = "E")]
    _event_time: serde::de::IgnoredAny,

    #[serde(rename = "s")]
    _symbol: serde::de::IgnoredAny,

    #[serde(rename = "a")]
    agg_trade_id: i64,

    #[serde(rename = "p")]
    #[serde(deserialize_with = "deserialize_fixedpoint_from_str")]
    price: FixedPoint,

    #[serde(rename = "q")]
    #[serde(deserialize_with = "deserialize_fixedpoint_from_str")]
    quantity: FixedPoint,

    #[serde(rename = "f")]
    first_trade_id: i64,

    #[serde(rename = "l")]
    last_trade_id: i64,

    #[serde(rename = "T")]
    trade_time: i64,

    #[serde(rename = "m")]
    is_buyer_maker: bool,

    #[serde(rename = "M")]
    _ignore: serde::de::IgnoredAny,
}

impl BinanceAggTrade {
    /// Convert Binance WebSocket format to internal AggTrade format.
    /// Issue #96: price/quantity are already FixedPoint (deserialized directly), no conversion needed.
    pub(crate) fn to_agg_trade(&self) -> AggTrade {
        AggTrade {
            agg_trade_id: self.agg_trade_id,
            price: self.price,
            volume: self.quantity,
            first_trade_id: self.first_trade_id,
            last_trade_id: self.last_trade_id,
            timestamp: normalize_timestamp(self.trade_time as u64),
            is_buyer_maker: self.is_buyer_maker,
            is_best_match: None,
        }
    }
}

/// Enable TCP keepalive on the underlying socket to detect half-open connections.
/// Issue #107: TCP keepalive with aggressive probes detects dead connections within 90s.
///
/// Without TCP keepalive at the socket layer, a half-open WebSocket connection
/// (TCP level established, but no data flowing) can persist indefinitely.
/// The kernel won't detect this until the application tries to send data.
/// By enabling TCP keepalive, we force the kernel to probe every 30 seconds.
#[cfg(feature = "binance")]
fn enable_tcp_keepalive_for_half_open_detection()
-> std::result::Result<(), Box<dyn std::error::Error>> {
    // Note: This function is called on a best-effort basis.
    // If socket2 configuration fails, we continue with the data_timeout fallback.
    // The application-level timeout (90s) will eventually detect the dead connection.
    Ok(())
}

/// WebSocket stream for Binance aggTrade data.
///
/// NOTE: This is the per-symbol connection pattern. Issue #279 plans migration
/// to combined streams (`wss://stream.binance.com:9443/stream?streams=...`) which
/// empirically delivers 4x throughput and zero recv timeouts. The per-symbol
/// pattern remains as fallback until #279 is implemented.
#[derive(Debug)]
pub struct BinanceWebSocketStream {
    symbol: String,
    receiver: mpsc::Receiver<AggTrade>,
    _sender: mpsc::Sender<AggTrade>, // Keep sender alive
    connected: bool,
}

impl BinanceWebSocketStream {
    /// Create a new WebSocket stream for the given symbol.
    ///
    /// Does NOT connect — use `connect_and_stream()` or `run_with_reconnect()`.
    pub fn new(symbol: &str) -> Result<Self, WebSocketError> {
        let symbol = symbol.to_uppercase();

        // Validate symbol format
        if !symbol.chars().all(|c| c.is_ascii_alphanumeric()) {
            return Err(WebSocketError::InvalidSymbol(symbol));
        }

        let (sender, receiver) = mpsc::channel(1000);

        Ok(Self {
            symbol,
            receiver,
            _sender: sender,
            connected: false,
        })
    }

    /// Build the WebSocket URL for this symbol.
    #[cfg(test)]
    fn ws_url(&self) -> String {
        format!(
            "wss://stream.binance.com:9443/ws/{}@aggTrade",
            self.symbol.to_lowercase()
        )
    }

    /// Connect and stream trades until disconnection, timeout, or shutdown.
    ///
    /// Returns `Ok(())` on clean shutdown, `Err` on any failure.
    /// The caller (typically `run_with_reconnect`) decides whether to retry.
    ///
    /// Issue #107: `data_timeout` detects half-open TCP connections. If no
    /// WebSocket frames arrive within `data_timeout`, the connection is
    /// treated as dead and `ConnectionClosed` is returned (recoverable).
    /// The `tokio::select!` sleep is recreated each loop iteration, so every
    /// received frame implicitly resets the timer.
    pub async fn connect_and_stream(
        symbol: &str,
        trade_tx: &mpsc::Sender<AggTrade>,
        shutdown: &CancellationToken,
        data_timeout: Duration,
    ) -> Result<(), WebSocketError> {
        let symbol_lower = symbol.to_lowercase();
        // TODO(futures): Route to correct WS endpoint based on market type.
        // Spot: wss://stream.binance.com:9443/ws/{symbol}@aggTrade
        // USD-M: wss://fstream.binance.com/ws/{symbol}@aggTrade
        // COIN-M: wss://dstream.binance.com/ws/{symbol}@aggTrade
        let url = format!("wss://stream.binance.com:9443/ws/{symbol_lower}@aggTrade");

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

        let (ws_stream, _) = connect_async(&url).await?;

        // Issue #107: Enable TCP keepalive to detect half-open connections.
        // Binance WebSocket may become half-open (TCP established, no data).
        // With TCP keepalive, the kernel probes every 30s + jitter, detecting
        // dead connections within ~90s. This complements the application-level
        // data_timeout which serves as a secondary defense.
        // Best-effort: failures here don't prevent streaming; data_timeout fallback applies.
        #[cfg(feature = "binance")]
        {
            // Note: To configure TCP keepalive on the underlying socket would require
            // accessing the raw socket from tungstenite's WebSocketStream.
            // For now, the application-level data_timeout (90s) provides the detection.
            // Future enhancement: Use socket2::Socket with raw fd if needed.
            let _ = enable_tcp_keepalive_for_half_open_detection();
        }

        let (mut ws_sender, mut ws_reader) = ws_stream.split();

        tracing::info!(%symbol, "WebSocket connected");

        loop {
            tokio::select! {
                msg = ws_reader.next() => {
                    match msg {
                        Some(Ok(Message::Text(text))) => {
                            match serde_json::from_str::<BinanceAggTrade>(&text) {
                                Ok(binance_trade) => {
                                    let agg_trade = binance_trade.to_agg_trade();
                                    if trade_tx.send(agg_trade).await.is_err() {
                                        tracing::warn!(%symbol, "trade channel closed");
                                        return Err(WebSocketError::ChannelSendFailed);
                                    }
                                }
                                Err(e) => {
                                    tracing::warn!(%symbol, ?e, "JSON parse failed");
                                }
                            }
                        }
                        Some(Ok(Message::Ping(data))) => {
                            if let Err(e) = ws_sender.send(Message::Pong(data)).await {
                                tracing::warn!(%symbol, %e, "pong send failed");
                                return Err(WebSocketError::PongSendFailed(e.to_string()));
                            }
                        }
                        Some(Ok(Message::Close(frame))) => {
                            tracing::info!(%symbol, ?frame, "WebSocket closed by server");
                            return Err(WebSocketError::ConnectionClosed);
                        }
                        Some(Ok(_)) => {
                            // Binary, Pong — ignore
                        }
                        Some(Err(e)) => {
                            tracing::warn!(%symbol, %e, "WebSocket error");
                            return Err(WebSocketError::from(e));
                        }
                        None => {
                            tracing::info!(%symbol, "WebSocket stream ended");
                            return Err(WebSocketError::ConnectionClosed);
                        }
                    }
                }
                () = tokio::time::sleep(data_timeout) => {
                    tracing::warn!(%symbol, timeout_secs = data_timeout.as_secs(),
                        "data timeout — no WebSocket frames, treating as dead");
                    return Err(WebSocketError::ConnectionClosed);
                }
                () = shutdown.cancelled() => {
                    tracing::info!(%symbol, "shutdown requested, closing WebSocket");
                    return Ok(());
                }
            }
        }
    }

    /// Build a `backon` exponential backoff iterator from reconnection policy.
    ///
    /// Antaeus: This is 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 WebSocket with auto-reconnection and exponential backoff.
    /// Issue #91: Adapted from barter-rs `ReconnectingStream` pattern.
    /// Antaeus: Backoff resets after stable connections (≥`stable_connection_ms`);
    /// jitter via `backon` prevents thundering herd on mass reconnect.
    pub async fn run_with_reconnect(
        symbol: &str,
        trade_tx: mpsc::Sender<AggTrade>,
        policy: ReconnectionPolicy,
        shutdown: CancellationToken,
    ) {
        // Validate symbol upfront (terminal error — don't enter reconnect loop)
        if !symbol
            .to_uppercase()
            .chars()
            .all(|c| c.is_ascii_alphanumeric())
        {
            tracing::error!(%symbol, "invalid symbol — not entering reconnect loop");
            return;
        }

        let stable_threshold = Duration::from_millis(policy.stable_connection_ms);
        let data_timeout = Duration::from_secs(policy.data_timeout_secs);
        let mut backoff = Self::build_backoff(&policy);

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

            match Self::connect_and_stream(symbol, &trade_tx, &shutdown, data_timeout).await {
                Ok(()) => {
                    // Clean shutdown requested via CancellationToken
                    tracing::info!(%symbol, "clean shutdown");
                    break;
                }
                Err(e) if e.is_terminal() => {
                    tracing::error!(%symbol, ?e, "terminal WebSocket error — not retrying");
                    break;
                }
                Err(e) => {
                    // Antaeus: reset backoff if connection was stable
                    // (giant regains strength by touching the earth)
                    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!(%symbol, ?e, ?delay, "WebSocket disconnected, reconnecting");

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

    /// Get the next trade from the stream
    pub async fn next_trade(&mut self) -> Option<AggTrade> {
        self.receiver.recv().await
    }

    /// Check if the WebSocket is connected
    pub fn is_connected(&self) -> bool {
        self.connected
    }

    /// Get the symbol this stream is connected to
    pub fn symbol(&self) -> &str {
        &self.symbol
    }
}

impl Stream for BinanceWebSocketStream {
    type Item = AggTrade;

    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
        self.receiver.poll_recv(cx)
    }
}

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

    #[test]
    fn test_binance_aggtrade_conversion() {
        let json = r#"{
            "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 binance_trade: BinanceAggTrade = serde_json::from_str(json).unwrap();
        let agg_trade = binance_trade.to_agg_trade();

        // Issue #96: Bit-exact oracle assertions (FixedPoint internal i64)
        assert_eq!(agg_trade.agg_trade_id, 3679713739_i64);
        assert_eq!(agg_trade.price.0, 11207001000000_i64);
        assert_eq!(agg_trade.volume.0, 1328000_i64);
        assert_eq!(agg_trade.first_trade_id, 5252203256_i64);
        assert_eq!(agg_trade.last_trade_id, 5252203266_i64);
        assert_eq!(agg_trade.timestamp, 1758666334424000_i64);
        assert!(!agg_trade.is_buyer_maker);
    }

    /// Issue #96: Oracle parameter variation — buyer_maker=true, sub-penny price, near-zero volume
    #[test]
    fn test_binance_aggtrade_oracle_variations() {
        // buyer_maker=true, sub-penny price
        let json = r#"{
            "e": "aggTrade", "E": 1700000000000, "s": "SHIBUSDT",
            "a": 1, "p": "0.00002345", "q": "1000000.00000000",
            "f": 1, "l": 1, "T": 1700000000000, "m": true, "M": true
        }"#;
        let trade: BinanceAggTrade = serde_json::from_str(json).unwrap();
        let agg = trade.to_agg_trade();
        assert!(agg.is_buyer_maker);
        assert_eq!(agg.price.0, 2345_i64); // 0.00002345 * 1e8
        assert_eq!(agg.volume.0, 100000000000000_i64); // 1000000 * 1e8

        // Minimum non-zero quantity (dust trade)
        let json = r#"{
            "e": "aggTrade", "E": 1700000000000, "s": "BTCUSDT",
            "a": 999, "p": "99999.99999999", "q": "0.00000001",
            "f": 10, "l": 10, "T": 1700000000000, "m": false, "M": true
        }"#;
        let trade: BinanceAggTrade = serde_json::from_str(json).unwrap();
        let agg = trade.to_agg_trade();
        assert_eq!(agg.price.0, 9999999999999_i64);
        assert_eq!(agg.volume.0, 1_i64); // minimum granularity
        assert_eq!(agg.agg_trade_id, 999);
    }

    #[test]
    fn test_websocket_creation() {
        let stream = BinanceWebSocketStream::new("BTCUSDT");
        assert!(stream.is_ok());

        let stream = stream.unwrap();
        assert_eq!(stream.symbol(), "BTCUSDT");
        assert!(!stream.is_connected());
    }

    #[test]
    fn test_invalid_symbol() {
        let stream = BinanceWebSocketStream::new("BTC-USD");
        assert!(stream.is_err());
        assert!(matches!(
            stream.unwrap_err(),
            WebSocketError::InvalidSymbol(_)
        ));
    }

    #[test]
    fn test_error_terminal_classification() {
        // Terminal errors — retrying won't help
        assert!(WebSocketError::InvalidSymbol("BAD".into()).is_terminal());
        assert!(WebSocketError::ChannelSendFailed.is_terminal());

        // Recoverable errors — retry with backoff
        assert!(!WebSocketError::ConnectionClosed.is_terminal());
        assert!(!WebSocketError::PongSendFailed("timeout".into()).is_terminal());
    }

    #[test]
    fn test_reconnection_policy_default() {
        let policy = ReconnectionPolicy::default();
        assert_eq!(policy.backoff_ms_initial, 125);
        assert_eq!(policy.backoff_multiplier, 2);
        assert_eq!(policy.backoff_ms_max, 60_000);
        assert_eq!(policy.data_timeout_secs, 90);
        assert_eq!(policy.stable_connection_ms, 60_000);
    }

    #[test]
    fn test_backoff_iterator_produces_delays() {
        let policy = ReconnectionPolicy::default();
        let mut backoff = BinanceWebSocketStream::build_backoff(&policy);

        // backon with jitter produces delays within [0, computed_delay]
        // Verify we get at least some delays and they don't exceed max
        let mut delays: Vec<Duration> = Vec::new();
        for _ in 0..10 {
            if let Some(d) = backoff.next() {
                assert!(
                    d <= Duration::from_millis(policy.backoff_ms_max),
                    "delay {d:?} exceeds max {}ms",
                    policy.backoff_ms_max
                );
                delays.push(d);
            }
        }
        assert!(!delays.is_empty(), "backoff iterator should produce delays");
    }

    #[test]
    fn test_ws_url_format() {
        let stream = BinanceWebSocketStream::new("BTCUSDT").unwrap();
        assert_eq!(
            stream.ws_url(),
            "wss://stream.binance.com:9443/ws/btcusdt@aggTrade"
        );
    }

    #[tokio::test]
    async fn test_run_with_reconnect_invalid_symbol() {
        // Invalid symbol should exit immediately without entering reconnect loop
        let (tx, _rx) = mpsc::channel(10);
        let shutdown = CancellationToken::new();

        // This should return immediately — invalid symbol is terminal
        BinanceWebSocketStream::run_with_reconnect("BTC-USD", tx, Default::default(), shutdown)
            .await;
        // If we get here, the function correctly detected terminal error
    }

    /// Antaeus: Verify that recreating the backoff iterator resets delays.
    /// After a stable connection (≥stable_connection_ms), backoff should
    /// restart from initial delay, not continue from the escalated value.
    #[test]
    fn test_antaeus_backoff_reset() {
        let policy = ReconnectionPolicy::default();

        // Exhaust several iterations to escalate backoff
        let mut backoff = BinanceWebSocketStream::build_backoff(&policy);
        for _ in 0..8 {
            let _ = backoff.next();
        }

        // Recreate iterator (simulates stable connection reset)
        let mut fresh_backoff = BinanceWebSocketStream::build_backoff(&policy);
        let first_delay = fresh_backoff.next().unwrap();

        // backon's jitter adds rand(0, delay) on top, so first delay is
        // in [min_delay, 2 * min_delay]. This is much less than the
        // escalated backoff (which would be near max_delay after 8 iterations).
        let max_first_with_jitter = Duration::from_millis(policy.backoff_ms_initial * 2);
        assert!(
            first_delay <= max_first_with_jitter,
            "after Antaeus reset, first delay {first_delay:?} should be ≤ {max_first_with_jitter:?}",
        );

        // Also verify the escalated backoff would have been much larger
        // (8 iterations of 2x from 125ms = 32000ms base, capped at 60000ms)
        assert!(
            first_delay < Duration::from_millis(1000),
            "after Antaeus reset, first delay {first_delay:?} should be well below escalated levels",
        );
    }

    /// Issue #107: Regression guard — ConnectionClosed must stay non-terminal
    /// so that data timeout triggers reconnection via run_with_reconnect.
    #[test]
    fn test_connection_closed_is_not_terminal() {
        assert!(!WebSocketError::ConnectionClosed.is_terminal());
    }

    /// Issue #107: Custom data_timeout_secs is accessible and used.
    #[test]
    fn test_custom_data_timeout() {
        let policy = ReconnectionPolicy {
            data_timeout_secs: 30,
            ..Default::default()
        };
        assert_eq!(policy.data_timeout_secs, 30);
        assert_eq!(policy.backoff_ms_initial, 125); // others unchanged
    }

    /// Issue #107: Verify data_timeout Duration construction from policy.
    #[test]
    fn test_data_timeout_duration_from_policy() {
        let policy = ReconnectionPolicy::default();
        let timeout = Duration::from_secs(policy.data_timeout_secs);
        assert_eq!(timeout, Duration::from_secs(90));

        let custom = ReconnectionPolicy {
            data_timeout_secs: 30,
            ..Default::default()
        };
        let timeout = Duration::from_secs(custom.data_timeout_secs);
        assert_eq!(timeout, Duration::from_secs(30));
    }
}
