"""Tests for CCXT exchange adapter.

This module provides comprehensive tests for the CCXT adapter including:
- Connection management
- Market data retrieval (ticker, orderbook, OHLCV)
- Account operations (balance, positions)
- Order management (create, cancel, get)
- Error handling and retry logic
"""

import pytest
from datetime import datetime
from typing import Any, Dict, List, Optional
from unittest.mock import Mock, patch, AsyncMock

# Skip all tests if CCXT is not installed
pytest.importorskip("ccxt", reason="CCXT not installed")

from openclaw.exchange.ccxt_adapter import (
    CCXTAdapter,
    CCXTExchangeFactory,
    RateLimitConfig,
    RetryConfig,
    ExchangeFeature,
    RateLimiter,
    create_binance,
    create_okx,
    create_bybit,
    create_kucoin,
)
from openclaw.exchange.base import (
    ExchangeError,
    AuthenticationError,
    InsufficientFundsError,
    InvalidOrderError,
    OrderNotFoundError,
)
from openclaw.exchange.models import OrderSide, OrderType, OrderStatus


class TestRateLimiter:
    """Tests for RateLimiter class."""

    @pytest.mark.asyncio
    async def test_acquire_token(self):
        """Test token acquisition."""
        config = RateLimitConfig(requests_per_second=10, burst_limit=5)
        limiter = RateLimiter(config)

        # Should acquire token immediately
        result = await limiter.acquire()
        assert result is True
        assert limiter.tokens == 4

    @pytest.mark.asyncio
    async def test_rate_limit_wait(self):
        """Test rate limit waiting."""
        config = RateLimitConfig(requests_per_second=100, burst_limit=1)
        limiter = RateLimiter(config)

        # Acquire first token
        await limiter.acquire()
        assert limiter.tokens == 0

        # Second acquisition should wait briefly
        result = await limiter.acquire()
        assert result is True


class TestCCXTAdapterInitialization:
    """Tests for CCXTAdapter initialization."""

    def test_init_with_valid_exchange(self):
        """Test initialization with valid exchange ID."""
        adapter = CCXTAdapter("binance")
        assert adapter.exchange_id == "binance"
        assert adapter.name == "binance"
        assert adapter.is_simulated is False

    def test_init_with_invalid_exchange(self):
        """Test initialization with invalid exchange ID."""
        with pytest.raises(ExchangeError, match="Unsupported exchange"):
            CCXTAdapter("invalid_exchange")

    def test_init_with_testnet(self):
        """Test initialization with testnet enabled."""
        adapter = CCXTAdapter("binance", testnet=True)
        assert adapter.testnet is True

    def test_init_with_custom_config(self):
        """Test initialization with custom rate and retry configs."""
        rate_config = RateLimitConfig(requests_per_second=5)
        retry_config = RetryConfig(max_retries=5)

        adapter = CCXTAdapter(
            "binance",
            rate_limit_config=rate_config,
            retry_config=retry_config,
        )

        assert adapter.rate_limit_config.requests_per_second == 5
        assert adapter.retry_config.max_retries == 5


class TestCCXTAdapterConnection:
    """Tests for CCXTAdapter connection management."""

    @pytest.fixture
    def mock_ccxt_client(self):
        """Create a mock CCXT client."""
        client = Mock()
        client.markets = {"BTC/USDT": {}, "ETH/USDT": {}}
        client.load_markets = AsyncMock()
        client.close = AsyncMock()
        return client

    @pytest.mark.asyncio
    async def test_connect_success(self, mock_ccxt_client):
        """Test successful connection."""
        adapter = CCXTAdapter("binance")

        with patch.object(adapter, "_create_client", return_value=mock_ccxt_client):
            result = await adapter.connect()
            assert result is True
            assert adapter.is_connected is True
            assert adapter._markets == {"BTC/USDT": {}, "ETH/USDT": {}}

    @pytest.mark.asyncio
    async def test_connect_authentication_error(self):
        """Test connection with authentication failure."""
        adapter = CCXTAdapter("binance")

        with patch.object(
            adapter, "_create_client", side_effect=Exception("authentication failed")
        ):
            with pytest.raises(AuthenticationError):
                await adapter.connect()

    @pytest.mark.asyncio
    async def test_disconnect(self, mock_ccxt_client):
        """Test disconnection."""
        adapter = CCXTAdapter("binance")
        adapter._client = mock_ccxt_client
        adapter._connected = True

        await adapter.disconnect()
        assert adapter.is_connected is False


class TestCCXTAdapterMarketData:
    """Tests for CCXTAdapter market data methods."""

    @pytest.fixture
    def connected_adapter(self):
        """Create a connected adapter with mocked client."""
        adapter = CCXTAdapter("binance")
        adapter._connected = True
        adapter._client = Mock()
        adapter._rate_limiter = Mock()
        adapter._rate_limiter.acquire = AsyncMock(return_value=True)
        return adapter

    @pytest.mark.asyncio
    async def test_get_ticker(self, connected_adapter):
        """Test getting ticker data."""
        mock_ticker = {
            "symbol": "BTC/USDT",
            "bid": 50000.0,
            "ask": 50001.0,
            "last": 50000.5,
            "high": 51000.0,
            "low": 49000.0,
            "baseVolume": 1000.0,
            "timestamp": 1700000000000,
        }

        connected_adapter._client.fetch_ticker = AsyncMock(return_value=mock_ticker)

        ticker = await connected_adapter.get_ticker("BTC/USDT")

        assert ticker.symbol == "BTC/USDT"
        assert ticker.bid == 50000.0
        assert ticker.ask == 50001.0
        assert ticker.last == 50000.5

    @pytest.mark.asyncio
    async def test_get_orderbook(self, connected_adapter):
        """Test getting order book."""
        mock_orderbook = {
            "symbol": "BTC/USDT",
            "bids": [[50000.0, 1.5], [49999.0, 2.0]],
            "asks": [[50001.0, 1.0], [50002.0, 2.5]],
            "timestamp": 1700000000000,
            "nonce": 12345,
            "datetime": "2023-11-14T12:00:00.000Z",
        }

        connected_adapter._client.fetch_order_book = AsyncMock(return_value=mock_orderbook)

        orderbook = await connected_adapter.get_orderbook("BTC/USDT", limit=10)

        assert orderbook["symbol"] == "BTC/USDT"
        assert orderbook["bids"] == [[50000.0, 1.5], [49999.0, 2.0]]
        assert orderbook["asks"] == [[50001.0, 1.0], [50002.0, 2.5]]
        assert orderbook["nonce"] == 12345

    @pytest.mark.asyncio
    async def test_get_ohlcv(self, connected_adapter):
        """Test getting OHLCV data."""
        mock_ohlcv = [
            [1700000000000, 50000.0, 51000.0, 49000.0, 50500.0, 100.0],
            [1700003600000, 50500.0, 51500.0, 50000.0, 51000.0, 150.0],
        ]

        connected_adapter._client.fetch_ohlcv = AsyncMock(return_value=mock_ohlcv)

        ohlcv = await connected_adapter.get_ohlcv("BTC/USDT", timeframe="1h", limit=2)

        assert len(ohlcv) == 2
        assert ohlcv[0]["open"] == 50000.0
        assert ohlcv[0]["high"] == 51000.0
        assert ohlcv[0]["low"] == 49000.0
        assert ohlcv[0]["close"] == 50500.0
        assert ohlcv[0]["volume"] == 100.0


class TestCCXTAdapterAccount:
    """Tests for CCXTAdapter account operations."""

    @pytest.fixture
    def connected_adapter(self):
        """Create a connected adapter with mocked client."""
        adapter = CCXTAdapter("binance")
        adapter._connected = True
        adapter._client = Mock()
        adapter._rate_limiter = Mock()
        adapter._rate_limiter.acquire = AsyncMock(return_value=True)
        return adapter

    @pytest.mark.asyncio
    async def test_get_balance(self, connected_adapter):
        """Test getting account balance."""
        mock_balance = {
            "free": {"BTC": 1.5, "USDT": 10000.0},
            "used": {"BTC": 0.5, "USDT": 2000.0},
        }

        connected_adapter._client.fetch_balance = AsyncMock(return_value=mock_balance)

        balances = await connected_adapter.get_balance()

        assert len(balances) == 2
        btc_balance = next(b for b in balances if b.asset == "BTC")
        assert btc_balance.free == 1.5
        assert btc_balance.locked == 0.5
        assert btc_balance.total == 2.0

    @pytest.mark.asyncio
    async def test_get_balance_with_filter(self, connected_adapter):
        """Test getting balance with asset filter."""
        mock_balance = {
            "free": {"BTC": 1.5, "USDT": 10000.0},
            "used": {"BTC": 0.5, "USDT": 2000.0},
        }

        connected_adapter._client.fetch_balance = AsyncMock(return_value=mock_balance)

        balances = await connected_adapter.get_balance(asset="BTC")

        assert len(balances) == 1
        assert balances[0].asset == "BTC"

    @pytest.mark.asyncio
    async def test_get_positions_futures(self, connected_adapter):
        """Test getting positions for futures trading."""
        connected_adapter._client.has = {"future": True}

        mock_positions = [
            {
                "symbol": "BTC/USDT:USDT",
                "contracts": 1.0,
                "side": "long",
                "entryPrice": 50000.0,
                "markPrice": 51000.0,
                "leverage": 10.0,
            }
        ]

        connected_adapter._client.fetch_positions = AsyncMock(return_value=mock_positions)

        positions = await connected_adapter.get_positions()

        assert len(positions) == 1
        assert positions[0].symbol == "BTC/USDT:USDT"
        assert positions[0].amount == 1.0
        assert positions[0].side == OrderSide.BUY
        assert positions[0].leverage == 10.0


class TestCCXTAdapterOrders:
    """Tests for CCXTAdapter order management."""

    @pytest.fixture
    def connected_adapter(self):
        """Create a connected adapter with mocked client."""
        adapter = CCXTAdapter("binance")
        adapter._connected = True
        adapter._client = Mock()
        adapter._rate_limiter = Mock()
        adapter._rate_limiter.acquire = AsyncMock(return_value=True)
        return adapter

    @pytest.mark.asyncio
    async def test_place_market_buy_order(self, connected_adapter):
        """Test placing a market buy order."""
        mock_order = {
            "id": "12345",
            "symbol": "BTC/USDT",
            "side": "buy",
            "type": "market",
            "amount": 0.1,
            "price": None,
            "status": "closed",
            "filled": 0.1,
            "timestamp": 1700000000000,
            "clientOrderId": "my-order-1",
        }

        connected_adapter._client.create_market_buy_order = AsyncMock(return_value=mock_order)

        order = await connected_adapter.place_order(
            symbol="BTC/USDT",
            side=OrderSide.BUY,
            amount=0.1,
            order_type="market",
        )

        assert order.order_id == "12345"
        assert order.symbol == "BTC/USDT"
        assert order.side == OrderSide.BUY
        assert order.order_type == OrderType.MARKET
        assert order.amount == 0.1
        assert order.filled_amount == 0.1

    @pytest.mark.asyncio
    async def test_place_limit_sell_order(self, connected_adapter):
        """Test placing a limit sell order."""
        mock_order = {
            "id": "12346",
            "symbol": "BTC/USDT",
            "side": "sell",
            "type": "limit",
            "amount": 0.5,
            "price": 55000.0,
            "status": "open",
            "filled": 0.0,
            "timestamp": 1700000000000,
        }

        connected_adapter._client.create_limit_sell_order = AsyncMock(return_value=mock_order)

        order = await connected_adapter.place_order(
            symbol="BTC/USDT",
            side=OrderSide.SELL,
            amount=0.5,
            price=55000.0,
            order_type="limit",
        )

        assert order.order_id == "12346"
        assert order.side == OrderSide.SELL
        assert order.order_type == OrderType.LIMIT
        assert order.price == 55000.0
        assert order.status == OrderStatus.OPEN

    @pytest.mark.asyncio
    async def test_place_order_insufficient_funds(self, connected_adapter):
        """Test placing order with insufficient funds."""
        connected_adapter._client.create_market_buy_order = AsyncMock(
            side_effect=Exception("insufficient balance")
        )

        with pytest.raises(InsufficientFundsError):
            await connected_adapter.place_order(
                symbol="BTC/USDT",
                side=OrderSide.BUY,
                amount=100.0,
                order_type="market",
            )

    @pytest.mark.asyncio
    async def test_cancel_order(self, connected_adapter):
        """Test cancelling an order."""
        connected_adapter._client.cancel_order = AsyncMock(return_value={"id": "12345"})

        result = await connected_adapter.cancel_order("12345")

        assert result is True

    @pytest.mark.asyncio
    async def test_cancel_order_not_found(self, connected_adapter):
        """Test cancelling a non-existent order."""
        connected_adapter._client.cancel_order = AsyncMock(
            side_effect=Exception("Order not found")
        )

        with pytest.raises(OrderNotFoundError):
            await connected_adapter.cancel_order("invalid-id")

    @pytest.mark.asyncio
    async def test_get_order(self, connected_adapter):
        """Test getting order by ID."""
        mock_order = {
            "id": "12345",
            "symbol": "BTC/USDT",
            "side": "buy",
            "type": "market",
            "amount": 0.1,
            "price": None,
            "status": "closed",
            "filled": 0.1,
            "timestamp": 1700000000000,
        }

        connected_adapter._client.fetch_order = AsyncMock(return_value=mock_order)

        order = await connected_adapter.get_order("12345")

        assert order is not None
        assert order.order_id == "12345"
        assert order.symbol == "BTC/USDT"

    @pytest.mark.asyncio
    async def test_get_order_not_found(self, connected_adapter):
        """Test getting non-existent order."""
        connected_adapter._client.fetch_order = AsyncMock(
            side_effect=Exception("Order not found")
        )

        order = await connected_adapter.get_order("invalid-id")
        assert order is None

    @pytest.mark.asyncio
    async def test_get_open_orders(self, connected_adapter):
        """Test getting open orders."""
        mock_orders = [
            {
                "id": "12345",
                "symbol": "BTC/USDT",
                "side": "buy",
                "type": "limit",
                "amount": 0.1,
                "price": 50000.0,
                "status": "open",
                "filled": 0.0,
                "timestamp": 1700000000000,
            }
        ]

        connected_adapter._client.fetch_open_orders = AsyncMock(return_value=mock_orders)

        orders = await connected_adapter.get_open_orders()

        assert len(orders) == 1
        assert orders[0].status == OrderStatus.OPEN


class NetworkError(Exception):
    """Mock network error for testing."""
    pass


class TestCCXTAdapterRetry:
    """Tests for CCXTAdapter retry logic."""

    @pytest.fixture
    def connected_adapter(self):
        """Create a connected adapter with mocked client."""
        adapter = CCXTAdapter("binance", retry_config=RetryConfig(max_retries=2, base_delay=0.01))
        adapter._connected = True
        adapter._client = Mock()
        adapter._rate_limiter = Mock()
        adapter._rate_limiter.acquire = AsyncMock(return_value=True)
        return adapter

    @pytest.mark.asyncio
    async def test_retry_on_network_error(self, connected_adapter):
        """Test retry on network error."""
        mock_ticker = {
            "symbol": "BTC/USDT",
            "bid": 50000.0,
            "ask": 50001.0,
            "last": 50000.5,
            "timestamp": 1700000000000,
        }

        # Fail twice, then succeed (use actual NetworkError type)
        connected_adapter._client.fetch_ticker = AsyncMock(
            side_effect=[
                NetworkError("Connection failed"),
                NetworkError("Connection failed"),
                mock_ticker,
            ]
        )

        ticker = await connected_adapter.get_ticker("BTC/USDT")

        assert ticker.last == 50000.5
        assert connected_adapter._client.fetch_ticker.call_count == 3

    @pytest.mark.asyncio
    async def test_retry_exhausted(self, connected_adapter):
        """Test failure when all retries are exhausted."""
        connected_adapter._client.fetch_ticker = AsyncMock(
            side_effect=NetworkError("Connection failed")
        )

        with pytest.raises(ExchangeError, match="Request failed after"):
            await connected_adapter.get_ticker("BTC/USDT")

        assert connected_adapter._client.fetch_ticker.call_count == 3  # 1 + 2 retries


class TestCCXTExchangeFactory:
    """Tests for CCXTExchangeFactory."""

    def test_create_exchange(self):
        """Test creating an exchange adapter."""
        adapter = CCXTExchangeFactory.create("binance", use_cached=False)

        assert isinstance(adapter, CCXTAdapter)
        assert adapter.exchange_id == "binance"

    def test_create_with_cache(self):
        """Test cached instance creation."""
        CCXTExchangeFactory.clear_cache()

        adapter1 = CCXTExchangeFactory.create("binance")
        adapter2 = CCXTExchangeFactory.create("binance")

        # Should return the same instance
        assert adapter1 is adapter2

    def test_configure_exchange(self):
        """Test configuring default exchange settings."""
        CCXTExchangeFactory.configure_exchange(
            "binance",
            api_key="test_key",
            api_secret="test_secret",
            testnet=True,
        )

        assert "binance" in CCXTExchangeFactory._default_configs
        config = CCXTExchangeFactory._default_configs["binance"]
        assert config["api_key"] == "test_key"
        assert config["api_secret"] == "test_secret"
        assert config["testnet"] is True

    def test_get_supported_exchanges(self):
        """Test getting list of supported exchanges."""
        exchanges = CCXTExchangeFactory.get_supported_exchanges()

        assert "binance" in exchanges
        assert "okx" in exchanges
        assert "bybit" in exchanges
        assert "kucoin" in exchanges

    def test_get_exchange_info(self):
        """Test getting exchange information."""
        info = CCXTExchangeFactory.get_exchange_info("binance")

        assert info["id"] == "binance"
        assert info["name"] == "Binance"

    def test_clear_cache(self):
        """Test clearing cache."""
        CCXTExchangeFactory.clear_cache()
        assert len(CCXTExchangeFactory._instances) == 0


class TestConvenienceFunctions:
    """Tests for convenience creation functions."""

    def test_create_binance(self):
        """Test create_binance function."""
        CCXTExchangeFactory.clear_cache()

        adapter = create_binance(api_key="key", api_secret="secret", testnet=True)

        assert adapter.exchange_id == "binance"
        assert adapter.api_key == "key"
        assert adapter.api_secret == "secret"
        assert adapter.testnet is True

    def test_create_okx(self):
        """Test create_okx function."""
        CCXTExchangeFactory.clear_cache()

        adapter = create_okx(
            api_key="key",
            api_secret="secret",
            passphrase="pass",
            testnet=True,
        )

        assert adapter.exchange_id == "okx"
        assert adapter.passphrase == "pass"

    def test_create_bybit(self):
        """Test create_bybit function."""
        CCXTExchangeFactory.clear_cache()

        adapter = create_bybit(api_key="key", api_secret="secret", testnet=True)

        assert adapter.exchange_id == "bybit"

    def test_create_kucoin(self):
        """Test create_kucoin function."""
        CCXTExchangeFactory.clear_cache()

        adapter = create_kucoin(
            api_key="key",
            api_secret="secret",
            passphrase="pass",
            testnet=True,
        )

        assert adapter.exchange_id == "kucoin"


class TestExchangeFeature:
    """Tests for ExchangeFeature enum."""

    def test_feature_values(self):
        """Test feature enum values."""
        assert ExchangeFeature.SPOT_TRADING.value == "spot"
        assert ExchangeFeature.MARGIN_TRADING.value == "margin"
        assert ExchangeFeature.FUTURES_TRADING.value == "futures"
        assert ExchangeFeature.LEVERAGE.value == "leverage"


if __name__ == "__main__":
    pytest.main([__file__, "-v"])
