import json
from decimal import Decimal

import pytest

from binance_quant.market_data import (
    TESTNET_STREAM_BASE_URL,
    kline_payload_to_candle,
    load_candles_csv,
    sample_realtime_market,
    stream_closed_klines,
)


def _closed_kline_payload(**overrides):
    kline = {
        "t": 1,
        "T": 2,
        "o": "10",
        "h": "12",
        "l": "9",
        "c": "11",
        "v": "1.5",
        "x": True,
    }
    kline.update(overrides.pop("k", {}))
    payload = {"s": "BTCUSDT", "k": kline}
    payload.update(overrides)
    return payload


def test_kline_payload_to_closed_candle():
    payload = {
        "s": "BTCUSDT",
        "k": {
            "t": 1,
            "T": 2,
            "o": "10",
            "h": "12",
            "l": "9",
            "c": "11",
            "v": "1.5",
            "x": True,
        },
    }

    candle = kline_payload_to_candle(payload)

    assert candle.symbol == "BTCUSDT"
    assert candle.close == Decimal("11")


def test_kline_payload_returns_none_for_open_candle():
    payload = {"s": "BTCUSDT", "k": {"x": False}}

    assert kline_payload_to_candle(payload) is None


def test_kline_payload_returns_none_for_missing_kline():
    assert kline_payload_to_candle({"s": "BTCUSDT"}) is None


def test_kline_payload_returns_none_for_non_true_closed_flag():
    payload = _closed_kline_payload(k={"x": "false"})

    assert kline_payload_to_candle(payload) is None


def test_kline_payload_raises_for_closed_candle_without_symbol():
    payload = _closed_kline_payload()
    del payload["s"]

    with pytest.raises(ValueError, match="symbol"):
        kline_payload_to_candle(payload)


def test_kline_payload_raises_for_closed_candle_missing_required_field():
    payload = _closed_kline_payload(k={"t": None})
    del payload["k"]["t"]

    with pytest.raises(ValueError, match="missing kline field"):
        kline_payload_to_candle(payload)


def test_kline_payload_raises_for_closed_candle_invalid_numeric_field():
    payload = _closed_kline_payload(k={"c": "bad"})

    with pytest.raises(ValueError, match="invalid kline field: c"):
        kline_payload_to_candle(payload)


def test_load_candles_csv(tmp_path):
    csv_path = tmp_path / "candles.csv"
    csv_path.write_text(
        "open_time,open,high,low,close,volume,close_time\n"
        "1,10,12,9,11,1.5,2\n",
        encoding="utf-8",
    )

    candles = list(load_candles_csv(csv_path, "BTCUSDT"))

    assert candles[0].close == Decimal("11")


@pytest.mark.asyncio
async def test_stream_closed_klines_yields_only_closed_candles(monkeypatch):
    messages = [
        json.dumps(_closed_kline_payload(k={"x": False, "c": "10"})),
        json.dumps(_closed_kline_payload(k={"x": True, "c": "11"})),
    ]
    connect_urls = []

    class FakeWebsocket:
        def __aiter__(self):
            self._messages = iter(messages)
            return self

        async def __anext__(self):
            try:
                return next(self._messages)
            except StopIteration as exc:
                raise StopAsyncIteration from exc

    class FakeConnect:
        async def __aenter__(self):
            return FakeWebsocket()

        async def __aexit__(self, exc_type, exc, traceback):
            return None

    def fake_connect(url):
        connect_urls.append(url)
        return FakeConnect()

    monkeypatch.setattr("binance_quant.market_data.websockets.connect", fake_connect)

    candles = [candle async for candle in stream_closed_klines("BTCUSDT")]

    assert connect_urls == [f"{TESTNET_STREAM_BASE_URL}/btcusdt@kline_1m"]
    assert len(candles) == 1
    assert candles[0].close == Decimal("11")


@pytest.mark.asyncio
async def test_sample_realtime_market_aggregates_book_depth_and_trades(monkeypatch):
    messages = [
        json.dumps(
            {
                "stream": "btcusdt@bookTicker",
                "data": {"s": "BTCUSDT", "b": "100", "B": "2", "a": "101", "A": "3"},
            }
        ),
        json.dumps(
            {
                "stream": "btcusdt@depth20@100ms",
                "data": {"s": "BTCUSDT", "bids": [["100", "2"]], "asks": [["101", "3"]]},
            }
        ),
        json.dumps(
            {
                "stream": "btcusdt@aggTrade",
                "data": {"s": "BTCUSDT", "p": "100", "q": "1", "m": False},
            }
        ),
        json.dumps(
            {
                "stream": "btcusdt@aggTrade",
                "data": {"s": "BTCUSDT", "p": "101", "q": "0.5", "m": True},
            }
        ),
    ]
    connect_urls = []

    class FakeWebsocket:
        def __aiter__(self):
            self._messages = iter(messages)
            return self

        async def __anext__(self):
            try:
                return next(self._messages)
            except StopIteration as exc:
                raise StopAsyncIteration from exc

    class FakeConnect:
        async def __aenter__(self):
            return FakeWebsocket()

        async def __aexit__(self, exc_type, exc, traceback):
            return None

    def fake_connect(url):
        connect_urls.append(url)
        return FakeConnect()

    monkeypatch.setattr("binance_quant.market_data.websockets.connect", fake_connect)

    result = await sample_realtime_market(["BTCUSDT"], stream_base_url="wss://stream.binance.com:9443/ws", sample_seconds=1)

    assert connect_urls == [
        "wss://stream.binance.com:9443/stream?streams=btcusdt@bookTicker/btcusdt@depth20@100ms/btcusdt@aggTrade"
    ]
    features = result["BTCUSDT"]
    assert features["status"] == "ok"
    assert features["sample_seconds"] == 1
    assert features["bookticker_updates"] == 1
    assert features["depth_updates"] == 1
    assert features["agg_trade_updates"] == 2
    assert features["spread_avg_bps"] == "99.5024875621890547263681592"
    assert features["best_bid_qty_avg"] == "2"
    assert features["best_ask_qty_avg"] == "3"
    assert features["depth_bid_notional_avg"] == "200"
    assert features["depth_ask_notional_avg"] == "303"
    assert features["book_imbalance_avg"] == "-0.2047713717693836978131212724"
    assert features["agg_trade_buy_quote_volume"] == "100"
    assert features["agg_trade_sell_quote_volume"] == "50.5"
    assert features["agg_trade_buy_sell_ratio"] == "1.98019801980198019801980198"
    assert features["large_trade_count"] == 2
    assert features["large_trade_quote_sum"] == "150.5"


@pytest.mark.asyncio
async def test_sample_realtime_market_marks_symbols_unavailable_when_stream_fails(monkeypatch):
    def fake_connect(url):
        raise OSError("network down")

    monkeypatch.setattr("binance_quant.market_data.websockets.connect", fake_connect)

    result = await sample_realtime_market(["BTCUSDT"], stream_base_url="wss://stream.binance.com:9443/ws", sample_seconds=1)

    assert result["BTCUSDT"]["status"] == "unavailable"
    assert result["BTCUSDT"]["bookticker_updates"] == 0
