"""WebSocket market data feeds for multiple exchanges.

This module provides WebSocket-based real-time market data from
multiple cryptocurrency exchanges (Binance, OKX, Bybit), supporting
klines (candlesticks), order book updates, ticker updates, and trade streams.
"""

from __future__ import annotations

import asyncio
import json
import time
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any, Callable, Coroutine, Dict, List, Optional, Set, Union

from loguru import logger

from openclaw.data.interface import Interval
from openclaw.exchange.models import Ticker

# Type alias for message handlers
TickerHandler = Callable[[str, Ticker], Coroutine[Any, Any, None]]
OrderBookHandler = Callable[[str, "OrderBook"], Coroutine[Any, Any, None]]
TradeHandler = Callable[[str, "Trade"], Coroutine[Any, Any, None]]
KlineHandler = Callable[[str, "KlineData"], Coroutine[Any, Any, None]]


class WebSocketError(Exception):
    """Base exception for WebSocket feed errors."""

    pass


class ConnectionError(WebSocketError):
    """Raised when WebSocket connection fails."""

    pass


class SubscriptionError(WebSocketError):
    """Raised when subscription fails."""

    pass


@dataclass(frozen=True)
class KlineData:
    """Kline (candlestick) data structure.

    Attributes:
        symbol: Trading pair symbol (e.g., 'BTC/USDT')
        interval: Time interval
        timestamp: Kline start time
        open: Opening price
        high: Highest price
        low: Lowest price
        close: Closing price
        volume: Trading volume
        quote_volume: Quote asset volume
        trades: Number of trades
        is_closed: Whether this kline is closed
    """

    symbol: str
    interval: str
    timestamp: datetime
    open: float
    high: float
    low: float
    close: float
    volume: float
    quote_volume: float
    trades: int
    is_closed: bool


@dataclass(frozen=True)
class OrderBookLevel:
    """Order book price level.

    Attributes:
        price: Price level
        amount: Amount at this price
    """

    price: float
    amount: float


@dataclass
class OrderBook:
    """Order book data structure.

    Attributes:
        symbol: Trading pair symbol
        bids: List of bid levels (price, amount), sorted by price descending
        asks: List of ask levels (price, amount), sorted by price ascending
        timestamp: Last update timestamp
        last_update_id: Last update ID for synchronization
    """

    symbol: str
    bids: List[OrderBookLevel] = field(default_factory=list)
    asks: List[OrderBookLevel] = field(default_factory=list)
    timestamp: datetime = field(default_factory=datetime.now)
    last_update_id: int = 0

    @property
    def best_bid(self) -> Optional[OrderBookLevel]:
        """Get the best (highest) bid."""
        return self.bids[0] if self.bids else None

    @property
    def best_ask(self) -> Optional[OrderBookLevel]:
        """Get the best (lowest) ask."""
        return self.asks[0] if self.asks else None

    @property
    def spread(self) -> float:
        """Calculate bid-ask spread."""
        if self.best_bid and self.best_ask:
            return self.best_ask.price - self.best_bid.price
        return 0.0

    @property
    def mid_price(self) -> float:
        """Calculate mid price."""
        if self.best_bid and self.best_ask:
            return (self.best_bid.price + self.best_ask.price) / 2
        return 0.0

    def update(self, bids: List[List[float]], asks: List[List[float]], update_id: int) -> None:
        """Update order book with new levels."""
        self.timestamp = datetime.now()
        self.last_update_id = update_id

        # Update bids
        for price, amount in bids:
            self._update_level(self.bids, price, amount, reverse=True)

        # Update asks
        for price, amount in asks:
            self._update_level(self.asks, price, amount, reverse=False)

    def _update_level(
        self, levels: List[OrderBookLevel], price: float, amount: float, reverse: bool
    ) -> None:
        """Update a single price level."""
        # Remove zero amounts
        if amount == 0:
            levels[:] = [l for l in levels if l.price != price]
            return

        # Update existing level or insert new one
        for i, level in enumerate(levels):
            if level.price == price:
                levels[i] = OrderBookLevel(price=price, amount=amount)
                return
            # Insert in sorted order
            if (reverse and price > level.price) or (not reverse and price < level.price):
                levels.insert(i, OrderBookLevel(price=price, amount=amount))
                return

        # Append if not found
        levels.append(OrderBookLevel(price=price, amount=amount))


@dataclass(frozen=True)
class Trade:
    """Trade data structure.

    Attributes:
        symbol: Trading pair symbol
        trade_id: Unique trade ID
        price: Trade price
        amount: Trade amount
        side: Trade side (buy/sell)
        timestamp: Trade timestamp
        is_buyer_maker: Whether buyer is maker
    """

    symbol: str
    trade_id: str
    price: float
    amount: float
    side: str  # "buy" or "sell"
    timestamp: datetime
    is_buyer_maker: bool


class ExchangeWebSocketFeed(ABC):
    """Abstract base class for exchange WebSocket feeds."""

    # Interval mapping for standardization
    INTERVAL_MAP: Dict[Interval, str] = {}

    def __init__(
        self,
        exchange_id: str,
        ws_url: str,
        reconnect_interval: float = 5.0,
        max_reconnect_attempts: int = 10,
        ping_interval: float = 20.0,
        ping_timeout: float = 10.0,
    ) -> None:
        """Initialize WebSocket feed.

        Args:
            exchange_id: Exchange identifier
            ws_url: WebSocket endpoint URL
            reconnect_interval: Seconds between reconnection attempts
            max_reconnect_attempts: Maximum reconnection attempts (0 = unlimited)
            ping_interval: Seconds between ping messages
            ping_timeout: Seconds to wait for pong response
        """
        self.exchange_id = exchange_id
        self.ws_url = ws_url
        self.reconnect_interval = reconnect_interval
        self.max_reconnect_attempts = max_reconnect_attempts
        self.ping_interval = ping_interval
        self.ping_timeout = ping_timeout

        self._ws: Any = None
        self._connected = False
        self._reconnect_count = 0
        self._stop_event = asyncio.Event()
        self._listen_task: Optional[asyncio.Task] = None
        self._ping_task: Optional[asyncio.Task] = None

        # Subscriptions
        self._subscribed_tickers: Set[str] = set()
        self._subscribed_klines: Dict[str, Set[str]] = {}  # symbol -> intervals
        self._subscribed_orderbooks: Set[str] = set()
        self._subscribed_trades: Set[str] = set()

        # Data caches
        self._orderbooks: Dict[str, OrderBook] = {}
        self._tickers: Dict[str, Ticker] = {}

        # Callbacks
        self._ticker_callbacks: Dict[str, List[TickerHandler]] = {}
        self._orderbook_callbacks: Dict[str, List[OrderBookHandler]] = {}
        self._trade_callbacks: Dict[str, List[TradeHandler]] = {}
        self._kline_callbacks: Dict[str, List[KlineHandler]] = {}

        self._lock = asyncio.Lock()

    @property
    def is_connected(self) -> bool:
        """Check if WebSocket is connected."""
        return self._connected

    def add_ticker_callback(self, symbol: str, callback: TickerHandler) -> None:
        """Add ticker callback for a symbol."""
        if symbol not in self._ticker_callbacks:
            self._ticker_callbacks[symbol] = []
        self._ticker_callbacks[symbol].append(callback)

    def remove_ticker_callback(self, symbol: str, callback: TickerHandler) -> None:
        """Remove ticker callback."""
        if symbol in self._ticker_callbacks and callback in self._ticker_callbacks[symbol]:
            self._ticker_callbacks[symbol].remove(callback)

    def add_orderbook_callback(self, symbol: str, callback: OrderBookHandler) -> None:
        """Add orderbook callback for a symbol."""
        if symbol not in self._orderbook_callbacks:
            self._orderbook_callbacks[symbol] = []
        self._orderbook_callbacks[symbol].append(callback)

    def remove_orderbook_callback(self, symbol: str, callback: OrderBookHandler) -> None:
        """Remove orderbook callback."""
        if symbol in self._orderbook_callbacks and callback in self._orderbook_callbacks[symbol]:
            self._orderbook_callbacks[symbol].remove(callback)

    def add_trade_callback(self, symbol: str, callback: TradeHandler) -> None:
        """Add trade callback for a symbol."""
        if symbol not in self._trade_callbacks:
            self._trade_callbacks[symbol] = []
        self._trade_callbacks[symbol].append(callback)

    def remove_trade_callback(self, symbol: str, callback: TradeHandler) -> None:
        """Remove trade callback."""
        if symbol in self._trade_callbacks and callback in self._trade_callbacks[symbol]:
            self._trade_callbacks[symbol].remove(callback)

    def add_kline_callback(self, symbol: str, callback: KlineHandler) -> None:
        """Add kline callback for a symbol."""
        if symbol not in self._kline_callbacks:
            self._kline_callbacks[symbol] = []
        self._kline_callbacks[symbol].append(callback)

    def remove_kline_callback(self, symbol: str, callback: KlineHandler) -> None:
        """Remove kline callback."""
        if symbol in self._kline_callbacks and callback in self._kline_callbacks[symbol]:
            self._kline_callbacks[symbol].remove(callback)

    async def connect(self) -> None:
        """Connect to WebSocket endpoint with automatic reconnection."""
        self._stop_event.clear()
        await self._connect_with_retry()

    async def _connect_with_retry(self) -> None:
        """Connect with exponential backoff retry logic."""
        base_delay = self.reconnect_interval

        while not self._stop_event.is_set():
            try:
                await self._connect()
                self._connected = True
                self._reconnect_count = 0
                logger.info(f"Connected to {self.exchange_id} WebSocket")

                # Start background tasks
                self._listen_task = asyncio.create_task(self._listen())
                self._ping_task = asyncio.create_task(self._ping_loop())

                # Resubscribe to previous subscriptions
                await self._resubscribe()

                # Wait for disconnect
                await self._listen_task

            except Exception as e:
                self._connected = False
                self._reconnect_count += 1

                if self.max_reconnect_attempts > 0 and self._reconnect_count > self.max_reconnect_attempts:
                    logger.error(f"Max reconnection attempts ({self.max_reconnect_attempts}) reached")
                    raise ConnectionError(f"Failed to connect after {self.max_reconnect_attempts} attempts")

                # Exponential backoff with jitter
                delay = min(base_delay * (2 ** (self._reconnect_count - 1)), 60)
                logger.warning(
                    f"WebSocket connection failed (attempt {self._reconnect_count}): {e}. "
                    f"Retrying in {delay}s..."
                )

                try:
                    await asyncio.wait_for(self._stop_event.wait(), timeout=delay)
                except asyncio.TimeoutError:
                    continue

    @abstractmethod
    async def _connect(self) -> None:
        """Establish WebSocket connection. Implement in subclass."""
        pass

    @abstractmethod
    async def _listen(self) -> None:
        """Listen for incoming messages. Implement in subclass."""
        pass

    @abstractmethod
    async def _ping_loop(self) -> None:
        """Send periodic ping messages. Implement in subclass."""
        pass

    async def disconnect(self) -> None:
        """Disconnect from WebSocket."""
        logger.info(f"Disconnecting from {self.exchange_id} WebSocket...")
        self._stop_event.set()
        self._connected = False

        if self._listen_task:
            self._listen_task.cancel()
            try:
                await self._listen_task
            except asyncio.CancelledError:
                pass

        if self._ping_task:
            self._ping_task.cancel()
            try:
                await self._ping_task
            except asyncio.CancelledError:
                pass

        await self._disconnect()
        logger.info(f"Disconnected from {self.exchange_id} WebSocket")

    @abstractmethod
    async def _disconnect(self) -> None:
        """Close WebSocket connection. Implement in subclass."""
        pass

    async def _resubscribe(self) -> None:
        """Resubscribe to all previous subscriptions after reconnection."""
        logger.info("Resubscribing to previous subscriptions...")

        # Resubscribe tickers
        for symbol in self._subscribed_tickers:
            try:
                await self.subscribe_ticker(symbol)
            except Exception as e:
                logger.error(f"Failed to resubscribe ticker {symbol}: {e}")

        # Resubscribe klines
        for symbol, intervals in self._subscribed_klines.items():
            for interval in intervals:
                try:
                    await self.subscribe_kline(symbol, interval)
                except Exception as e:
                    logger.error(f"Failed to resubscribe kline {symbol} {interval}: {e}")

        # Resubscribe orderbooks
        for symbol in self._subscribed_orderbooks:
            try:
                await self.subscribe_orderbook(symbol)
            except Exception as e:
                logger.error(f"Failed to resubscribe orderbook {symbol}: {e}")

        # Resubscribe trades
        for symbol in self._subscribed_trades:
            try:
                await self.subscribe_trades(symbol)
            except Exception as e:
                logger.error(f"Failed to resubscribe trades {symbol}: {e}")

    @abstractmethod
    async def subscribe_ticker(self, symbol: str) -> None:
        """Subscribe to ticker updates.

        Args:
            symbol: Trading pair symbol
        """
        pass

    @abstractmethod
    async def unsubscribe_ticker(self, symbol: str) -> None:
        """Unsubscribe from ticker updates."""
        pass

    @abstractmethod
    async def subscribe_kline(self, symbol: str, interval: str | Interval) -> None:
        """Subscribe to kline (candlestick) updates.

        Args:
            symbol: Trading pair symbol
            interval: Time interval (e.g., '1m', '5m', '1h')
        """
        pass

    @abstractmethod
    async def unsubscribe_kline(self, symbol: str, interval: str | Interval) -> None:
        """Unsubscribe from kline updates."""
        pass

    @abstractmethod
    async def subscribe_orderbook(self, symbol: str, depth: int = 20) -> None:
        """Subscribe to order book updates.

        Args:
            symbol: Trading pair symbol
            depth: Order book depth
        """
        pass

    @abstractmethod
    async def unsubscribe_orderbook(self, symbol: str) -> None:
        """Unsubscribe from order book updates."""
        pass

    @abstractmethod
    async def subscribe_trades(self, symbol: str) -> None:
        """Subscribe to trade stream.

        Args:
            symbol: Trading pair symbol
        """
        pass

    @abstractmethod
    async def unsubscribe_trades(self, symbol: str) -> None:
        """Unsubscribe from trade stream."""
        pass

    async def _notify_ticker_callbacks(self, symbol: str, ticker: Ticker) -> None:
        """Notify all ticker callbacks for a symbol."""
        callbacks = self._ticker_callbacks.get(symbol, [])
        for callback in callbacks:
            try:
                await callback(symbol, ticker)
            except Exception as e:
                logger.error(f"Error in ticker callback: {e}")

    async def _notify_orderbook_callbacks(self, symbol: str, orderbook: OrderBook) -> None:
        """Notify all orderbook callbacks for a symbol."""
        callbacks = self._orderbook_callbacks.get(symbol, [])
        for callback in callbacks:
            try:
                await callback(symbol, orderbook)
            except Exception as e:
                logger.error(f"Error in orderbook callback: {e}")

    async def _notify_trade_callbacks(self, symbol: str, trade: Trade) -> None:
        """Notify all trade callbacks for a symbol."""
        callbacks = self._trade_callbacks.get(symbol, [])
        for callback in callbacks:
            try:
                await callback(symbol, trade)
            except Exception as e:
                logger.error(f"Error in trade callback: {e}")

    async def _notify_kline_callbacks(self, symbol: str, kline: KlineData) -> None:
        """Notify all kline callbacks for a symbol."""
        callbacks = self._kline_callbacks.get(symbol, [])
        for callback in callbacks:
            try:
                await callback(symbol, kline)
            except Exception as e:
                logger.error(f"Error in kline callback: {e}")

    def get_orderbook(self, symbol: str) -> Optional[OrderBook]:
        """Get current order book for a symbol."""
        return self._orderbooks.get(symbol)

    def get_ticker(self, symbol: str) -> Optional[Ticker]:
        """Get current ticker for a symbol."""
        return self._tickers.get(symbol)


class BinanceWebSocketFeed(ExchangeWebSocketFeed):
    """Binance WebSocket feed implementation."""

    # Binance WebSocket endpoints
    SPOT_WS_URL = "wss://stream.binance.com:9443/ws"
    FUTURES_WS_URL = "wss://fstream.binance.com/ws"
    SPOT_STREAM_URL = "wss://stream.binance.com:9443/stream"
    FUTURES_STREAM_URL = "wss://fstream.binance.com/stream"

    # Interval mapping
    INTERVAL_MAP = {
        Interval.MINUTE_1: "1m",
        Interval.MINUTE_5: "5m",
        Interval.MINUTE_15: "15m",
        Interval.MINUTE_30: "30m",
        Interval.HOUR_1: "1h",
        Interval.HOUR_4: "4h",
        Interval.DAY_1: "1d",
        Interval.WEEK_1: "1w",
        Interval.MONTH_1: "1M",
    }

    def __init__(
        self,
        futures: bool = False,
        reconnect_interval: float = 5.0,
        max_reconnect_attempts: int = 10,
    ) -> None:
        """Initialize Binance WebSocket feed.

        Args:
            futures: Use futures market instead of spot
            reconnect_interval: Seconds between reconnection attempts
            max_reconnect_attempts: Maximum reconnection attempts
        """
        self.futures = futures
        ws_url = self.FUTURES_WS_URL if futures else self.SPOT_WS_URL
        exchange_id = "binance_futures" if futures else "binance"

        super().__init__(
            exchange_id=exchange_id,
            ws_url=ws_url,
            reconnect_interval=reconnect_interval,
            max_reconnect_attempts=max_reconnect_attempts,
        )

        self._combined_streams: Set[str] = set()
        self._stream_id = 1

    def _normalize_symbol(self, symbol: str) -> str:
        """Normalize symbol to Binance format (remove /)."""
        return symbol.replace("/", "").lower()

    def _denormalize_symbol(self, symbol: str) -> str:
        """Convert Binance symbol back to standard format."""
        symbol_lower = symbol.lower()
        # Try to identify quote currency
        for quote in ["usdt", "busd", "btc", "eth", "bnb", "usd"]:
            if symbol_lower.endswith(quote):
                base = symbol[:-len(quote)]
                return f"{base.upper()}/{quote.upper()}"
        return symbol.upper()

    def _get_interval_str(self, interval: str | Interval) -> str:
        """Convert interval to Binance format."""
        if isinstance(interval, Interval):
            return self.INTERVAL_MAP.get(interval, "1h")
        return interval

    async def _connect(self) -> None:
        """Connect to Binance WebSocket."""
        try:
            import websockets
        except ImportError:
            raise ImportError("websockets package is required. Install with: pip install websockets")

        self._ws = await websockets.connect(
            self.ws_url,
            ping_interval=None,  # We handle ping ourselves
            ping_timeout=None,
        )

    async def _disconnect(self) -> None:
        """Close Binance WebSocket connection."""
        if self._ws:
            await self._ws.close()
            self._ws = None

    async def _listen(self) -> None:
        """Listen for Binance WebSocket messages."""
        try:
            import websockets
        except ImportError:
            raise ImportError("websockets package is required")

        while not self._stop_event.is_set():
            try:
                message = await self._ws.recv()
                await self._handle_message(message)
            except websockets.exceptions.ConnectionClosed:
                logger.warning("WebSocket connection closed")
                break
            except Exception as e:
                logger.error(f"Error handling message: {e}")

    async def _ping_loop(self) -> None:
        """Send periodic ping messages."""
        while not self._stop_event.is_set():
            try:
                await asyncio.wait_for(self._stop_event.wait(), timeout=self.ping_interval)
            except asyncio.TimeoutError:
                if self._ws and self._connected:
                    try:
                        await self._ws.send(json.dumps({"method": "LIST_SUBSCRIPTIONS", "id": self._stream_id}))
                        self._stream_id += 1
                    except Exception as e:
                        logger.error(f"Ping failed: {e}")

    async def _handle_message(self, message: str | bytes) -> None:
        """Handle incoming WebSocket message."""
        try:
            if isinstance(message, bytes):
                message = message.decode("utf-8")

            data = json.loads(message)

            # Handle combined stream response
            if "stream" in data:
                stream = data["stream"]
                payload = data["data"]

                if "@kline" in stream:
                    await self._handle_kline(payload)
                elif "@depth" in stream:
                    await self._handle_orderbook(payload)
                elif "@trade" in stream:
                    await self._handle_trade(payload)
                elif "@ticker" in stream:
                    await self._handle_ticker(payload)

            # Handle single stream response
            elif "e" in data:
                event_type = data["e"]

                if event_type == "kline":
                    await self._handle_kline(data)
                elif event_type == "depthUpdate":
                    await self._handle_orderbook(data)
                elif event_type == "trade":
                    await self._handle_trade(data)
                elif event_type == "24hrTicker":
                    await self._handle_ticker(data)

        except json.JSONDecodeError as e:
            logger.error(f"Failed to parse message: {e}")
        except Exception as e:
            logger.error(f"Error processing message: {e}")

    async def _handle_ticker(self, data: Dict[str, Any]) -> None:
        """Handle ticker message."""
        try:
            symbol = self._denormalize_symbol(data.get("s", ""))

            ticker = Ticker(
                symbol=symbol,
                bid=float(data.get("b", 0)),
                ask=float(data.get("a", 0)),
                last=float(data.get("c", 0)),
                high=float(data.get("h", 0)) if "h" in data else None,
                low=float(data.get("l", 0)) if "l" in data else None,
                volume=float(data.get("v", 0)) if "v" in data else None,
                timestamp=datetime.now(),
            )

            self._tickers[symbol] = ticker
            await self._notify_ticker_callbacks(symbol, ticker)

        except Exception as e:
            logger.error(f"Error handling ticker: {e}")

    async def _handle_kline(self, data: Dict[str, Any]) -> None:
        """Handle kline message."""
        try:
            k = data["k"]
            symbol = self._denormalize_symbol(k["s"])

            kline = KlineData(
                symbol=symbol,
                interval=k["i"],
                timestamp=datetime.fromtimestamp(k["t"] / 1000),
                open=float(k["o"]),
                high=float(k["h"]),
                low=float(k["l"]),
                close=float(k["c"]),
                volume=float(k["v"]),
                quote_volume=float(k["q"]),
                trades=int(k["n"]),
                is_closed=k["x"],
            )

            await self._notify_kline_callbacks(symbol, kline)

        except Exception as e:
            logger.error(f"Error handling kline: {e}")

    async def _handle_orderbook(self, data: Dict[str, Any]) -> None:
        """Handle order book update."""
        try:
            symbol = self._denormalize_symbol(data.get("s", ""))

            if symbol not in self._orderbooks:
                self._orderbooks[symbol] = OrderBook(symbol=symbol)

            orderbook = self._orderbooks[symbol]

            # Parse bids and asks
            bids = [[float(p), float(a)] for p, a in data.get("b", [])]
            asks = [[float(p), float(a)] for p, a in data.get("a", [])]

            update_id = data.get("u", 0)
            orderbook.update(bids, asks, update_id)

            await self._notify_orderbook_callbacks(symbol, orderbook)

        except Exception as e:
            logger.error(f"Error handling orderbook: {e}")

    async def _handle_trade(self, data: Dict[str, Any]) -> None:
        """Handle trade message."""
        try:
            symbol = self._denormalize_symbol(data.get("s", ""))

            trade = Trade(
                symbol=symbol,
                trade_id=str(data.get("t", "")),
                price=float(data.get("p", 0)),
                amount=float(data.get("q", 0)),
                side="buy" if data.get("m", False) else "sell",
                timestamp=datetime.fromtimestamp(data.get("T", 0) / 1000),
                is_buyer_maker=data.get("m", False),
            )

            await self._notify_trade_callbacks(symbol, trade)

        except Exception as e:
            logger.error(f"Error handling trade: {e}")

    async def subscribe_ticker(self, symbol: str) -> None:
        """Subscribe to ticker updates."""
        norm_symbol = self._normalize_symbol(symbol)
        stream = f"{norm_symbol}@ticker"

        self._subscribed_tickers.add(symbol)
        self._combined_streams.add(stream)
        await self._subscribe_stream(stream)

        logger.info(f"Subscribed to ticker {symbol}")

    async def unsubscribe_ticker(self, symbol: str) -> None:
        """Unsubscribe from ticker updates."""
        norm_symbol = self._normalize_symbol(symbol)
        stream = f"{norm_symbol}@ticker"

        self._subscribed_tickers.discard(symbol)
        self._combined_streams.discard(stream)
        await self._unsubscribe_stream(stream)

        logger.info(f"Unsubscribed from ticker {symbol}")

    async def subscribe_kline(self, symbol: str, interval: str | Interval) -> None:
        """Subscribe to kline updates."""
        norm_symbol = self._normalize_symbol(symbol)
        interval_str = self._get_interval_str(interval)
        stream = f"{norm_symbol}@kline_{interval_str}"

        # Track subscription
        if symbol not in self._subscribed_klines:
            self._subscribed_klines[symbol] = set()
        self._subscribed_klines[symbol].add(interval_str)

        # Subscribe via combined stream if needed
        if stream not in self._combined_streams:
            self._combined_streams.add(stream)
            await self._subscribe_stream(stream)

        logger.info(f"Subscribed to kline {symbol} {interval_str}")

    async def unsubscribe_kline(self, symbol: str, interval: str | Interval) -> None:
        """Unsubscribe from kline updates."""
        norm_symbol = self._normalize_symbol(symbol)
        interval_str = self._get_interval_str(interval)
        stream = f"{norm_symbol}@kline_{interval_str}"

        if symbol in self._subscribed_klines:
            self._subscribed_klines[symbol].discard(interval_str)

        self._combined_streams.discard(stream)
        await self._unsubscribe_stream(stream)

        logger.info(f"Unsubscribed from kline {symbol} {interval_str}")

    async def subscribe_orderbook(self, symbol: str, depth: int = 20) -> None:
        """Subscribe to order book updates."""
        norm_symbol = self._normalize_symbol(symbol)
        stream = f"{norm_symbol}@depth@{depth}ms"

        self._subscribed_orderbooks.add(symbol)
        self._combined_streams.add(stream)
        await self._subscribe_stream(stream)

        # Initialize order book snapshot
        await self._fetch_orderbook_snapshot(symbol)

        logger.info(f"Subscribed to orderbook {symbol}")

    async def unsubscribe_orderbook(self, symbol: str) -> None:
        """Unsubscribe from order book updates."""
        norm_symbol = self._normalize_symbol(symbol)

        # Find and remove all depth streams for this symbol
        streams_to_remove = [s for s in self._combined_streams if s.startswith(f"{norm_symbol}@depth")]
        for stream in streams_to_remove:
            self._combined_streams.discard(stream)
            await self._unsubscribe_stream(stream)

        self._subscribed_orderbooks.discard(symbol)

        logger.info(f"Unsubscribed from orderbook {symbol}")

    async def subscribe_trades(self, symbol: str) -> None:
        """Subscribe to trade stream."""
        norm_symbol = self._normalize_symbol(symbol)
        stream = f"{norm_symbol}@trade"

        self._subscribed_trades.add(symbol)
        self._combined_streams.add(stream)
        await self._subscribe_stream(stream)

        logger.info(f"Subscribed to trades {symbol}")

    async def unsubscribe_trades(self, symbol: str) -> None:
        """Unsubscribe from trade stream."""
        norm_symbol = self._normalize_symbol(symbol)
        stream = f"{norm_symbol}@trade"

        self._subscribed_trades.discard(symbol)
        self._combined_streams.discard(stream)
        await self._unsubscribe_stream(stream)

        logger.info(f"Unsubscribed from trades {symbol}")

    async def _subscribe_stream(self, stream: str) -> None:
        """Subscribe to a stream."""
        if self._ws:
            msg = {
                "method": "SUBSCRIBE",
                "params": [stream],
                "id": self._stream_id,
            }
            await self._ws.send(json.dumps(msg))
            self._stream_id += 1

    async def _unsubscribe_stream(self, stream: str) -> None:
        """Unsubscribe from a stream."""
        if self._ws:
            msg = {
                "method": "UNSUBSCRIBE",
                "params": [stream],
                "id": self._stream_id,
            }
            await self._ws.send(json.dumps(msg))
            self._stream_id += 1

    async def _fetch_orderbook_snapshot(self, symbol: str) -> None:
        """Fetch initial order book snapshot."""
        try:
            import aiohttp
        except ImportError:
            logger.warning("aiohttp not installed, skipping orderbook snapshot")
            return

        try:
            norm_symbol = self._normalize_symbol(symbol)
            base_url = (
                "https://fapi.binance.com/fapi/v1"
                if self.futures
                else "https://api.binance.com/api/v3"
            )

            async with aiohttp.ClientSession() as session:
                async with session.get(
                    f"{base_url}/depth",
                    params={"symbol": norm_symbol.upper(), "limit": 100},
                ) as response:
                    if response.status == 200:
                        data = await response.json()

                        if symbol not in self._orderbooks:
                            self._orderbooks[symbol] = OrderBook(symbol=symbol)

                        orderbook = self._orderbooks[symbol]

                        # Parse snapshot
                        bids = [[float(p), float(a)] for p, a in data.get("bids", [])]
                        asks = [[float(p), float(a)] for p, a in data.get("asks", [])]

                        orderbook.bids = [OrderBookLevel(price=p, amount=a) for p, a in bids]
                        orderbook.asks = [OrderBookLevel(price=p, amount=a) for p, a in asks]
                        orderbook.bids.sort(key=lambda x: x.price, reverse=True)
                        orderbook.asks.sort(key=lambda x: x.price)
                        orderbook.last_update_id = data.get("lastUpdateId", 0)

        except Exception as e:
            logger.error(f"Failed to fetch orderbook snapshot for {symbol}: {e}")


class OKXWebSocketFeed(ExchangeWebSocketFeed):
    """OKX WebSocket feed implementation."""

    # OKX WebSocket endpoints
    WS_URL = "wss://ws.okx.com:8443/ws/v5/public"
    WS_URL_PRIVATE = "wss://ws.okx.com:8443/ws/v5/private"

    # Interval mapping
    INTERVAL_MAP = {
        Interval.MINUTE_1: "1m",
        Interval.MINUTE_5: "5m",
        Interval.MINUTE_15: "15m",
        Interval.MINUTE_30: "30m",
        Interval.HOUR_1: "1H",
        Interval.HOUR_4: "4H",
        Interval.DAY_1: "1D",
        Interval.WEEK_1: "1W",
        Interval.MONTH_1: "1M",
    }

    def __init__(
        self,
        reconnect_interval: float = 5.0,
        max_reconnect_attempts: int = 10,
    ) -> None:
        """Initialize OKX WebSocket feed."""
        super().__init__(
            exchange_id="okx",
            ws_url=self.WS_URL,
            reconnect_interval=reconnect_interval,
            max_reconnect_attempts=max_reconnect_attempts,
        )

    def _normalize_symbol(self, symbol: str) -> str:
        """Normalize symbol to OKX format."""
        return symbol.replace("/", "-")

    def _denormalize_symbol(self, symbol: str) -> str:
        """Convert OKX symbol back to standard format."""
        return symbol.replace("-", "/")

    def _get_interval_str(self, interval: str | Interval) -> str:
        """Convert interval to OKX format."""
        if isinstance(interval, Interval):
            return self.INTERVAL_MAP.get(interval, "1H")
        return interval

    async def _connect(self) -> None:
        """Connect to OKX WebSocket."""
        try:
            import websockets
        except ImportError:
            raise ImportError("websockets package is required. Install with: pip install websockets")

        self._ws = await websockets.connect(
            self.ws_url,
            ping_interval=None,
            ping_timeout=None,
        )

    async def _disconnect(self) -> None:
        """Close OKX WebSocket connection."""
        if self._ws:
            await self._ws.close()
            self._ws = None

    async def _listen(self) -> None:
        """Listen for OKX WebSocket messages."""
        try:
            import websockets
        except ImportError:
            raise ImportError("websockets package is required")

        while not self._stop_event.is_set():
            try:
                message = await self._ws.recv()
                await self._handle_message(message)
            except websockets.exceptions.ConnectionClosed:
                logger.warning("OKX WebSocket connection closed")
                break
            except Exception as e:
                logger.error(f"Error handling OKX message: {e}")

    async def _ping_loop(self) -> None:
        """Send periodic ping messages."""
        while not self._stop_event.is_set():
            try:
                await asyncio.wait_for(self._stop_event.wait(), timeout=self.ping_interval)
            except asyncio.TimeoutError:
                if self._ws and self._connected:
                    try:
                        await self._ws.send("ping")
                    except Exception as e:
                        logger.error(f"OKX ping failed: {e}")

    async def _handle_message(self, message: str | bytes) -> None:
        """Handle incoming WebSocket message."""
        try:
            if isinstance(message, bytes):
                message = message.decode("utf-8")

            # Handle ping/pong
            if message == "pong":
                return

            data = json.loads(message)

            # Handle event messages
            if "event" in data:
                event = data.get("event")
                if event == "subscribe":
                    logger.debug(f"Subscribed to {data.get('arg', {}).get('channel')}")
                elif event == "error":
                    logger.error(f"OKX error: {data}")
                return

            # Handle data messages
            if "arg" in data and "data" in data:
                arg = data["arg"]
                channel = arg.get("channel", "")
                symbol = self._denormalize_symbol(arg.get("instId", ""))

                for item in data.get("data", []):
                    if "tickers" in channel or channel == "tickers":
                        await self._handle_ticker(symbol, item)
                    elif "candle" in channel:
                        await self._handle_kline(symbol, item, channel)
                    elif "books" in channel:
                        await self._handle_orderbook(symbol, item)
                    elif "trade" in channel:
                        await self._handle_trade(symbol, item)

        except json.JSONDecodeError as e:
            logger.error(f"Failed to parse OKX message: {e}")
        except Exception as e:
            logger.error(f"Error processing OKX message: {e}")

    async def _handle_ticker(self, symbol: str, data: Dict[str, Any]) -> None:
        """Handle ticker message."""
        try:
            ticker = Ticker(
                symbol=symbol,
                bid=float(data.get("bidPx", 0)),
                ask=float(data.get("askPx", 0)),
                last=float(data.get("last", 0)),
                high=float(data.get("high24h", 0)) if data.get("high24h") else None,
                low=float(data.get("low24h", 0)) if data.get("low24h") else None,
                volume=float(data.get("vol24h", 0)) if data.get("vol24h") else None,
                timestamp=datetime.now(),
            )

            self._tickers[symbol] = ticker
            await self._notify_ticker_callbacks(symbol, ticker)

        except Exception as e:
            logger.error(f"Error handling OKX ticker: {e}")

    async def _handle_kline(self, symbol: str, data: Dict[str, Any], channel: str) -> None:
        """Handle kline message."""
        try:
            # Extract interval from channel (e.g., "candle1m" -> "1m")
            interval = channel.replace("candle", "")

            kline = KlineData(
                symbol=symbol,
                interval=interval,
                timestamp=datetime.fromtimestamp(int(data.get("ts", 0)) / 1000),
                open=float(data.get("o", 0)),
                high=float(data.get("h", 0)),
                low=float(data.get("l", 0)),
                close=float(data.get("c", 0)),
                volume=float(data.get("vol", 0)),
                quote_volume=float(data.get("volCcy", 0)),
                trades=0,  # OKX doesn't provide trade count
                is_closed=data.get("confirm") == "1",
            )

            await self._notify_kline_callbacks(symbol, kline)

        except Exception as e:
            logger.error(f"Error handling OKX kline: {e}")

    async def _handle_orderbook(self, symbol: str, data: Dict[str, Any]) -> None:
        """Handle order book update."""
        try:
            if symbol not in self._orderbooks:
                self._orderbooks[symbol] = OrderBook(symbol=symbol)

            orderbook = self._orderbooks[symbol]

            # Parse bids and asks
            bids = [[float(p), float(a)] for p, a in data.get("bids", [])]
            asks = [[float(p), float(a)] for p, a in data.get("asks", [])]

            # Use seqId or ts as update id
            update_id = int(data.get("seqId", data.get("ts", 0)))
            orderbook.update(bids, asks, update_id)

            await self._notify_orderbook_callbacks(symbol, orderbook)

        except Exception as e:
            logger.error(f"Error handling OKX orderbook: {e}")

    async def _handle_trade(self, symbol: str, data: Dict[str, Any]) -> None:
        """Handle trade message."""
        try:
            trade = Trade(
                symbol=symbol,
                trade_id=data.get("tradeId", ""),
                price=float(data.get("px", 0)),
                amount=float(data.get("sz", 0)),
                side=data.get("side", "buy"),
                timestamp=datetime.fromtimestamp(int(data.get("ts", 0)) / 1000),
                is_buyer_maker=data.get("side") == "sell",
            )

            await self._notify_trade_callbacks(symbol, trade)

        except Exception as e:
            logger.error(f"Error handling OKX trade: {e}")

    async def subscribe_ticker(self, symbol: str) -> None:
        """Subscribe to ticker updates."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_tickers.add(symbol)

        await self._subscribe_channel("tickers", {"instId": norm_symbol})
        logger.info(f"Subscribed to OKX ticker {symbol}")

    async def unsubscribe_ticker(self, symbol: str) -> None:
        """Unsubscribe from ticker updates."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_tickers.discard(symbol)

        await self._unsubscribe_channel("tickers", {"instId": norm_symbol})
        logger.info(f"Unsubscribed from OKX ticker {symbol}")

    async def subscribe_kline(self, symbol: str, interval: str | Interval) -> None:
        """Subscribe to kline updates."""
        norm_symbol = self._normalize_symbol(symbol)
        interval_str = self._get_interval_str(interval)

        if symbol not in self._subscribed_klines:
            self._subscribed_klines[symbol] = set()
        self._subscribed_klines[symbol].add(interval_str)

        channel = f"candle{interval_str}"
        await self._subscribe_channel(channel, {"instId": norm_symbol})
        logger.info(f"Subscribed to OKX kline {symbol} {interval_str}")

    async def unsubscribe_kline(self, symbol: str, interval: str | Interval) -> None:
        """Unsubscribe from kline updates."""
        norm_symbol = self._normalize_symbol(symbol)
        interval_str = self._get_interval_str(interval)

        if symbol in self._subscribed_klines:
            self._subscribed_klines[symbol].discard(interval_str)

        channel = f"candle{interval_str}"
        await self._unsubscribe_channel(channel, {"instId": norm_symbol})
        logger.info(f"Unsubscribed from OKX kline {symbol} {interval_str}")

    async def subscribe_orderbook(self, symbol: str, depth: int = 20) -> None:
        """Subscribe to order book updates."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_orderbooks.add(symbol)

        # OKX uses books5, books50, or books for different depths
        if depth <= 5:
            channel = "books5"
        elif depth <= 50:
            channel = "books50"
        else:
            channel = "books"

        await self._subscribe_channel(channel, {"instId": norm_symbol})
        logger.info(f"Subscribed to OKX orderbook {symbol}")

    async def unsubscribe_orderbook(self, symbol: str) -> None:
        """Unsubscribe from order book updates."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_orderbooks.discard(symbol)

        # Unsubscribe from all book channels
        for channel in ["books5", "books50", "books"]:
            await self._unsubscribe_channel(channel, {"instId": norm_symbol})

        logger.info(f"Unsubscribed from OKX orderbook {symbol}")

    async def subscribe_trades(self, symbol: str) -> None:
        """Subscribe to trade stream."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_trades.add(symbol)

        await self._subscribe_channel("trades", {"instId": norm_symbol})
        logger.info(f"Subscribed to OKX trades {symbol}")

    async def unsubscribe_trades(self, symbol: str) -> None:
        """Unsubscribe from trade stream."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_trades.discard(symbol)

        await self._unsubscribe_channel("trades", {"instId": norm_symbol})
        logger.info(f"Unsubscribed from OKX trades {symbol}")

    async def _subscribe_channel(self, channel: str, args: Dict[str, Any]) -> None:
        """Subscribe to a channel."""
        if self._ws:
            msg = {
                "op": "subscribe",
                "args": [{"channel": channel, **args}],
            }
            await self._ws.send(json.dumps(msg))

    async def _unsubscribe_channel(self, channel: str, args: Dict[str, Any]) -> None:
        """Unsubscribe from a channel."""
        if self._ws:
            msg = {
                "op": "unsubscribe",
                "args": [{"channel": channel, **args}],
            }
            await self._ws.send(json.dumps(msg))


class BybitWebSocketFeed(ExchangeWebSocketFeed):
    """Bybit WebSocket feed implementation."""

    # Bybit WebSocket endpoints
    SPOT_WS_URL = "wss://stream.bybit.com/v5/public/spot"
    LINEAR_WS_URL = "wss://stream.bybit.com/v5/public/linear"
    INVERSE_WS_URL = "wss://stream.bybit.com/v5/public/inverse"

    # Interval mapping
    INTERVAL_MAP = {
        Interval.MINUTE_1: "1",
        Interval.MINUTE_5: "5",
        Interval.MINUTE_15: "15",
        Interval.MINUTE_30: "30",
        Interval.HOUR_1: "60",
        Interval.HOUR_4: "240",
        Interval.DAY_1: "D",
        Interval.WEEK_1: "W",
        Interval.MONTH_1: "M",
    }

    def __init__(
        self,
        market: str = "spot",
        reconnect_interval: float = 5.0,
        max_reconnect_attempts: int = 10,
    ) -> None:
        """Initialize Bybit WebSocket feed.

        Args:
            market: Market type (spot, linear, inverse)
            reconnect_interval: Seconds between reconnection attempts
            max_reconnect_attempts: Maximum reconnection attempts
        """
        self.market = market
        ws_urls = {
            "spot": self.SPOT_WS_URL,
            "linear": self.LINEAR_WS_URL,
            "inverse": self.INVERSE_WS_URL,
        }
        ws_url = ws_urls.get(market, self.SPOT_WS_URL)

        super().__init__(
            exchange_id=f"bybit_{market}",
            ws_url=ws_url,
            reconnect_interval=reconnect_interval,
            max_reconnect_attempts=max_reconnect_attempts,
        )

    def _normalize_symbol(self, symbol: str) -> str:
        """Normalize symbol to Bybit format."""
        return symbol.replace("/", "")

    def _denormalize_symbol(self, symbol: str) -> str:
        """Convert Bybit symbol back to standard format."""
        # Try to identify quote currency
        for quote in ["USDT", "USDC", "BTC", "ETH", "USD"]:
            if symbol.endswith(quote):
                base = symbol[: -len(quote)]
                return f"{base}/{quote}"
        return symbol

    def _get_interval_str(self, interval: str | Interval) -> str:
        """Convert interval to Bybit format."""
        if isinstance(interval, Interval):
            return self.INTERVAL_MAP.get(interval, "60")
        # Map string intervals
        mapping = {"1m": "1", "5m": "5", "15m": "15", "30m": "30", "1h": "60", "4h": "240", "1d": "D", "1w": "W", "1M": "M"}
        return mapping.get(interval, interval)

    async def _connect(self) -> None:
        """Connect to Bybit WebSocket."""
        try:
            import websockets
        except ImportError:
            raise ImportError("websockets package is required. Install with: pip install websockets")

        self._ws = await websockets.connect(
            self.ws_url,
            ping_interval=None,
            ping_timeout=None,
        )

    async def _disconnect(self) -> None:
        """Close Bybit WebSocket connection."""
        if self._ws:
            await self._ws.close()
            self._ws = None

    async def _listen(self) -> None:
        """Listen for Bybit WebSocket messages."""
        try:
            import websockets
        except ImportError:
            raise ImportError("websockets package is required")

        while not self._stop_event.is_set():
            try:
                message = await self._ws.recv()
                await self._handle_message(message)
            except websockets.exceptions.ConnectionClosed:
                logger.warning("Bybit WebSocket connection closed")
                break
            except Exception as e:
                logger.error(f"Error handling Bybit message: {e}")

    async def _ping_loop(self) -> None:
        """Send periodic ping messages."""
        while not self._stop_event.is_set():
            try:
                await asyncio.wait_for(self._stop_event.wait(), timeout=self.ping_interval)
            except asyncio.TimeoutError:
                if self._ws and self._connected:
                    try:
                        await self._ws.send(json.dumps({"op": "ping"}))
                    except Exception as e:
                        logger.error(f"Bybit ping failed: {e}")

    async def _handle_message(self, message: str | bytes) -> None:
        """Handle incoming WebSocket message."""
        try:
            if isinstance(message, bytes):
                message = message.decode("utf-8")

            data = json.loads(message)

            # Handle pong
            if data.get("op") == "pong":
                return

            # Handle subscription success
            if data.get("success") is True and data.get("op") == "subscribe":
                logger.debug(f"Bybit subscription successful: {data}")
                return

            # Handle errors
            if data.get("success") is False:
                logger.error(f"Bybit error: {data}")
                return

            # Handle topic messages
            topic = data.get("topic", "")
            if not topic:
                return

            symbol = ""
            for item in data.get("data", []):
                symbol = self._denormalize_symbol(item.get("symbol", ""))

                if "tickers" in topic:
                    await self._handle_ticker(symbol, item)
                elif "kline" in topic:
                    await self._handle_kline(symbol, item)
                elif "orderbook" in topic or "depth" in topic:
                    await self._handle_orderbook(symbol, item)
                elif "trade" in topic or "publicTrade" in topic:
                    await self._handle_trade(symbol, item)

        except json.JSONDecodeError as e:
            logger.error(f"Failed to parse Bybit message: {e}")
        except Exception as e:
            logger.error(f"Error processing Bybit message: {e}")

    async def _handle_ticker(self, symbol: str, data: Dict[str, Any]) -> None:
        """Handle ticker message."""
        try:
            ticker = Ticker(
                symbol=symbol,
                bid=float(data.get("bid1Price", 0)),
                ask=float(data.get("ask1Price", 0)),
                last=float(data.get("lastPrice", 0)),
                high=float(data.get("highPrice24h", 0)) if data.get("highPrice24h") else None,
                low=float(data.get("lowPrice24h", 0)) if data.get("lowPrice24h") else None,
                volume=float(data.get("volume24h", 0)) if data.get("volume24h") else None,
                timestamp=datetime.now(),
            )

            self._tickers[symbol] = ticker
            await self._notify_ticker_callbacks(symbol, ticker)

        except Exception as e:
            logger.error(f"Error handling Bybit ticker: {e}")

    async def _handle_kline(self, symbol: str, data: Dict[str, Any]) -> None:
        """Handle kline message."""
        try:
            kline = KlineData(
                symbol=symbol,
                interval=data.get("interval", ""),
                timestamp=datetime.fromtimestamp(int(data.get("start", 0)) / 1000),
                open=float(data.get("open", 0)),
                high=float(data.get("high", 0)),
                low=float(data.get("low", 0)),
                close=float(data.get("close", 0)),
                volume=float(data.get("volume", 0)),
                quote_volume=float(data.get("turnover", 0)),
                trades=int(data.get("trades", 0)) if data.get("trades") else 0,
                is_closed=data.get("confirm", False),
            )

            await self._notify_kline_callbacks(symbol, kline)

        except Exception as e:
            logger.error(f"Error handling Bybit kline: {e}")

    async def _handle_orderbook(self, symbol: str, data: Dict[str, Any]) -> None:
        """Handle order book update."""
        try:
            if symbol not in self._orderbooks:
                self._orderbooks[symbol] = OrderBook(symbol=symbol)

            orderbook = self._orderbooks[symbol]

            # Parse bids and asks
            bids = [[float(p), float(a)] for p, a in data.get("b", [])]
            asks = [[float(p), float(a)] for p, a in data.get("a", [])]

            update_id = int(data.get("u", data.get("ts", 0)))
            orderbook.update(bids, asks, update_id)

            await self._notify_orderbook_callbacks(symbol, orderbook)

        except Exception as e:
            logger.error(f"Error handling Bybit orderbook: {e}")

    async def _handle_trade(self, symbol: str, data: Dict[str, Any]) -> None:
        """Handle trade message."""
        try:
            trade = Trade(
                symbol=symbol,
                trade_id=str(data.get("i", "")),
                price=float(data.get("p", 0)),
                amount=float(data.get("v", data.get("q", 0))),
                side="buy" if data.get("S") == "Buy" or data.get("side") == "buy" else "sell",
                timestamp=datetime.fromtimestamp(int(data.get("T", data.get("ts", 0))) / 1000),
                is_buyer_maker=data.get("m", False),
            )

            await self._notify_trade_callbacks(symbol, trade)

        except Exception as e:
            logger.error(f"Error handling Bybit trade: {e}")

    async def subscribe_ticker(self, symbol: str) -> None:
        """Subscribe to ticker updates."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_tickers.add(symbol)

        await self._subscribe_topic(f"tickers.{norm_symbol}")
        logger.info(f"Subscribed to Bybit ticker {symbol}")

    async def unsubscribe_ticker(self, symbol: str) -> None:
        """Unsubscribe from ticker updates."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_tickers.discard(symbol)

        await self._unsubscribe_topic(f"tickers.{norm_symbol}")
        logger.info(f"Unsubscribed from Bybit ticker {symbol}")

    async def subscribe_kline(self, symbol: str, interval: str | Interval) -> None:
        """Subscribe to kline updates."""
        norm_symbol = self._normalize_symbol(symbol)
        interval_str = self._get_interval_str(interval)

        if symbol not in self._subscribed_klines:
            self._subscribed_klines[symbol] = set()
        self._subscribed_klines[symbol].add(interval_str)

        await self._subscribe_topic(f"kline.{interval_str}.{norm_symbol}")
        logger.info(f"Subscribed to Bybit kline {symbol} {interval_str}")

    async def unsubscribe_kline(self, symbol: str, interval: str | Interval) -> None:
        """Unsubscribe from kline updates."""
        norm_symbol = self._normalize_symbol(symbol)
        interval_str = self._get_interval_str(interval)

        if symbol in self._subscribed_klines:
            self._subscribed_klines[symbol].discard(interval_str)

        await self._unsubscribe_topic(f"kline.{interval_str}.{norm_symbol}")
        logger.info(f"Unsubscribed from Bybit kline {symbol} {interval_str}")

    async def subscribe_orderbook(self, symbol: str, depth: int = 20) -> None:
        """Subscribe to order book updates."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_orderbooks.add(symbol)

        # Bybit uses different depth levels
        if depth <= 1:
            topic = f"orderbook.1.{norm_symbol}"
        elif depth <= 50:
            topic = f"orderbook.50.{norm_symbol}"
        else:
            topic = f"orderbook.200.{norm_symbol}"

        await self._subscribe_topic(topic)
        logger.info(f"Subscribed to Bybit orderbook {symbol}")

    async def unsubscribe_orderbook(self, symbol: str) -> None:
        """Unsubscribe from order book updates."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_orderbooks.discard(symbol)

        # Unsubscribe from all depth levels
        for depth in ["1", "50", "200"]:
            await self._unsubscribe_topic(f"orderbook.{depth}.{norm_symbol}")

        logger.info(f"Unsubscribed from Bybit orderbook {symbol}")

    async def subscribe_trades(self, symbol: str) -> None:
        """Subscribe to trade stream."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_trades.add(symbol)

        await self._subscribe_topic(f"publicTrade.{norm_symbol}")
        logger.info(f"Subscribed to Bybit trades {symbol}")

    async def unsubscribe_trades(self, symbol: str) -> None:
        """Unsubscribe from trade stream."""
        norm_symbol = self._normalize_symbol(symbol)
        self._subscribed_trades.discard(symbol)

        await self._unsubscribe_topic(f"publicTrade.{norm_symbol}")
        logger.info(f"Unsubscribed from Bybit trades {symbol}")

    async def _subscribe_topic(self, topic: str) -> None:
        """Subscribe to a topic."""
        if self._ws:
            msg = {
                "op": "subscribe",
                "args": [topic],
            }
            await self._ws.send(json.dumps(msg))

    async def _unsubscribe_topic(self, topic: str) -> None:
        """Unsubscribe from a topic."""
        if self._ws:
            msg = {
                "op": "unsubscribe",
                "args": [topic],
            }
            await self._ws.send(json.dumps(msg))


class WebSocketManager:
    """Manager for multiple WebSocket feeds from different exchanges.

    Provides a unified interface for subscribing to market data from
    multiple exchanges simultaneously with automatic reconnection,
    subscription management, and data routing.
    """

    def __init__(self) -> None:
        """Initialize the WebSocket manager."""
        self._feeds: Dict[str, ExchangeWebSocketFeed] = {}
        self._subscriptions: Dict[str, Dict[str, Any]] = {}  # symbol -> subscription info
        self._running = False
        self._lock = asyncio.Lock()
        self._tasks: List[asyncio.Task] = []

    def add_feed(self, feed: ExchangeWebSocketFeed) -> None:
        """Add a WebSocket feed to the manager.

        Args:
            feed: WebSocket feed instance
        """
        self._feeds[feed.exchange_id] = feed
        logger.info(f"Added feed: {feed.exchange_id}")

    def remove_feed(self, exchange_id: str) -> None:
        """Remove a WebSocket feed."""
        if exchange_id in self._feeds:
            del self._feeds[exchange_id]
            logger.info(f"Removed feed: {exchange_id}")

    def get_feed(self, exchange_id: str) -> Optional[ExchangeWebSocketFeed]:
        """Get a feed by exchange ID."""
        return self._feeds.get(exchange_id)

    def list_feeds(self) -> List[str]:
        """List all connected exchange IDs."""
        return list(self._feeds.keys())

    async def start(self) -> None:
        """Start all WebSocket feeds in the background."""
        if self._running:
            return

        self._running = True
        # Start connection loops in background tasks so we don't block
        for feed in self._feeds.values():
            task = asyncio.create_task(feed.connect())
            self._tasks.append(task)
        
        logger.info(f"WebSocket manager started with {len(self._feeds)} feeds in background")

    async def stop(self) -> None:
        """Stop all WebSocket feeds."""
        if not self._running:
            return

        self._running = False
        
        # Stop feeds gracefully
        tasks = [feed.disconnect() for feed in self._feeds.values()]
        await asyncio.gather(*tasks, return_exceptions=True)
        
        # Cancel background tasks
        for task in self._tasks:
            if not task.done():
                task.cancel()
        
        if self._tasks:
            await asyncio.gather(*self._tasks, return_exceptions=True)
            self._tasks = []
            
        logger.info("WebSocket manager stopped")

    async def subscribe_ticker(
        self, symbol: str, callback: TickerHandler, exchange_id: Optional[str] = None
    ) -> None:
        """Subscribe to ticker updates.

        Args:
            symbol: Trading pair symbol (e.g., "BTC/USDT")
            callback: Async callback function receiving (symbol, ticker)
            exchange_id: Specific exchange or None for all exchanges
        """
        feeds = [self._feeds[exchange_id]] if exchange_id else list(self._feeds.values())

        for feed in feeds:
            feed.add_ticker_callback(symbol, callback)
            if self._running:
                try:
                    await feed.subscribe_ticker(symbol)
                except Exception as e:
                    logger.error(f"Failed to subscribe to ticker {symbol} on {feed.exchange_id}: {e}")

    async def unsubscribe_ticker(self, symbol: str, exchange_id: Optional[str] = None) -> None:
        """Unsubscribe from ticker updates."""
        feeds = [self._feeds[exchange_id]] if exchange_id else list(self._feeds.values())

        for feed in feeds:
            if self._running:
                try:
                    await feed.unsubscribe_ticker(symbol)
                except Exception as e:
                    logger.error(f"Failed to unsubscribe ticker {symbol} on {feed.exchange_id}: {e}")

    async def subscribe_orderbook(
        self, symbol: str, callback: OrderBookHandler, exchange_id: Optional[str] = None
    ) -> None:
        """Subscribe to order book updates.

        Args:
            symbol: Trading pair symbol
            callback: Async callback function receiving (symbol, orderbook)
            exchange_id: Specific exchange or None for all exchanges
        """
        feeds = [self._feeds[exchange_id]] if exchange_id else list(self._feeds.values())

        for feed in feeds:
            feed.add_orderbook_callback(symbol, callback)
            if self._running:
                try:
                    await feed.subscribe_orderbook(symbol)
                except Exception as e:
                    logger.error(f"Failed to subscribe to orderbook {symbol} on {feed.exchange_id}: {e}")

    async def unsubscribe_orderbook(self, symbol: str, exchange_id: Optional[str] = None) -> None:
        """Unsubscribe from order book updates."""
        feeds = [self._feeds[exchange_id]] if exchange_id else list(self._feeds.values())

        for feed in feeds:
            if self._running:
                try:
                    await feed.unsubscribe_orderbook(symbol)
                except Exception as e:
                    logger.error(f"Failed to unsubscribe orderbook {symbol} on {feed.exchange_id}: {e}")

    async def subscribe_trades(
        self, symbol: str, callback: TradeHandler, exchange_id: Optional[str] = None
    ) -> None:
        """Subscribe to trade stream.

        Args:
            symbol: Trading pair symbol
            callback: Async callback function receiving (symbol, trade)
            exchange_id: Specific exchange or None for all exchanges
        """
        feeds = [self._feeds[exchange_id]] if exchange_id else list(self._feeds.values())

        for feed in feeds:
            feed.add_trade_callback(symbol, callback)
            if self._running:
                try:
                    await feed.subscribe_trades(symbol)
                except Exception as e:
                    logger.error(f"Failed to subscribe to trades {symbol} on {feed.exchange_id}: {e}")

    async def unsubscribe_trades(self, symbol: str, exchange_id: Optional[str] = None) -> None:
        """Unsubscribe from trade stream."""
        feeds = [self._feeds[exchange_id]] if exchange_id else list(self._feeds.values())

        for feed in feeds:
            if self._running:
                try:
                    await feed.unsubscribe_trades(symbol)
                except Exception as e:
                    logger.error(f"Failed to unsubscribe trades {symbol} on {feed.exchange_id}: {e}")

    async def subscribe_klines(
        self,
        symbol: str,
        interval: str,
        callback: KlineHandler,
        exchange_id: Optional[str] = None,
    ) -> None:
        """Subscribe to kline updates.

        Args:
            symbol: Trading pair symbol
            interval: Time interval (e.g., '1m', '5m', '1h')
            callback: Async callback function receiving (symbol, kline)
            exchange_id: Specific exchange or None for all exchanges
        """
        feeds = [self._feeds[exchange_id]] if exchange_id else list(self._feeds.values())

        for feed in feeds:
            feed.add_kline_callback(symbol, callback)
            if self._running:
                try:
                    await feed.subscribe_kline(symbol, interval)
                except Exception as e:
                    logger.error(f"Failed to subscribe to kline {symbol} on {feed.exchange_id}: {e}")

    async def unsubscribe_klines(
        self, symbol: str, interval: str, exchange_id: Optional[str] = None
    ) -> None:
        """Unsubscribe from kline updates."""
        feeds = [self._feeds[exchange_id]] if exchange_id else list(self._feeds.values())

        for feed in feeds:
            if self._running:
                try:
                    await feed.unsubscribe_kline(symbol, interval)
                except Exception as e:
                    logger.error(f"Failed to unsubscribe kline {symbol} on {feed.exchange_id}: {e}")

    def get_ticker(self, symbol: str, exchange_id: str) -> Optional[Ticker]:
        """Get current ticker for a symbol from a specific exchange."""
        feed = self._feeds.get(exchange_id)
        return feed.get_ticker(symbol) if feed else None

    def get_orderbook(self, symbol: str, exchange_id: str) -> Optional[OrderBook]:
        """Get current order book for a symbol from a specific exchange."""
        feed = self._feeds.get(exchange_id)
        return feed.get_orderbook(symbol) if feed else None

    def get_all_tickers(self, symbol: str) -> Dict[str, Ticker]:
        """Get tickers for a symbol from all exchanges."""
        return {
            exchange_id: feed.get_ticker(symbol)
            for exchange_id, feed in self._feeds.items()
            if feed.get_ticker(symbol) is not None
        }

    def get_all_orderbooks(self, symbol: str) -> Dict[str, OrderBook]:
        """Get order books for a symbol from all exchanges."""
        return {
            exchange_id: feed.get_orderbook(symbol)
            for exchange_id, feed in self._feeds.items()
            if feed.get_orderbook(symbol) is not None
        }


# Global WebSocket manager instance
_ws_manager: Optional[WebSocketManager] = None


def get_websocket_manager() -> WebSocketManager:
    """Get or create the global WebSocket manager instance."""
    global _ws_manager
    if _ws_manager is None:
        _ws_manager = WebSocketManager()
    return _ws_manager


def create_default_manager() -> WebSocketManager:
    """Create a default WebSocket manager with common exchanges.

    Returns:
        WebSocketManager with Binance, OKX, and Bybit feeds configured
    """
    manager = get_websocket_manager()

    # Add Binance feeds
    manager.add_feed(BinanceWebSocketFeed(futures=False))
    manager.add_feed(BinanceWebSocketFeed(futures=True))

    # Add OKX feed
    manager.add_feed(OKXWebSocketFeed())

    # Add Bybit feeds
    manager.add_feed(BybitWebSocketFeed(market="spot"))
    manager.add_feed(BybitWebSocketFeed(market="linear"))

    return manager
