"""BybitFunding adapter — survivorship-free USDT-perp funding from Bybit v5 market data.

ALL HTTP is injected/mocked — no network in these tests. The adapter takes a `fetch(url)->dict` callable
(JSON endpoints) and a `fetch_html(url)->str` callable (the public.bybit.com directory listing), so the
tests drive it with canned responses and assert pagination, 8h->daily aggregation, survivorship, and the
active / ever-listed universe parsing.
"""
from __future__ import annotations

from fxhnt.adapters.data.bybit_funding import BYBIT_FUNDING_FLOOR_MS, BybitFunding

_DAY_MS = 86_400_000
_8H_MS = 8 * 3_600_000


def _funding_row(ts_ms: int, rate: float) -> dict:
    return {"symbol": "X", "fundingRate": str(rate), "fundingRateTimestamp": str(ts_ms)}


class _PagedFunding:
    """Mock of /v5/market/funding/history: serves rows newest-first, honoring the `endTime` cursor exactly
    like Bybit (returns up to `limit` rows with fundingRateTimestamp <= endTime, descending). When the
    caller walks endTime back past the oldest row, it returns an empty list (stops the loop)."""

    def __init__(self, rows: list[dict], *, limit: int = 200) -> None:
        # rows given oldest-first; store descending (newest first) like the API.
        self._rows = sorted(rows, key=lambda r: int(r["fundingRateTimestamp"]), reverse=True)
        self._limit = limit
        self.calls: list[int | None] = []

    def fetch(self, url: str) -> dict:
        end = _parse_qs_int(url, "endTime")
        self.calls.append(end)
        cand = self._rows if end is None else [r for r in self._rows
                                               if int(r["fundingRateTimestamp"]) <= end]
        page = cand[: self._limit]
        return {"retCode": 0, "result": {"list": page}}


def _parse_qs_int(url: str, key: str) -> int | None:
    from urllib.parse import parse_qs, urlparse
    q = parse_qs(urlparse(url).query)
    return int(q[key][0]) if key in q else None


def test_funding_history_paginates_backward_and_aggregates_8h_to_daily():
    # 3 funding stamps on day 100 (8h apart) + 1 on day 99, spread across >1 page (limit=2 forces paging).
    d100 = 100 * _DAY_MS
    d99 = 99 * _DAY_MS
    rows = [
        _funding_row(d99 + _8H_MS, -0.0003),
        _funding_row(d100, 0.0001),
        _funding_row(d100 + _8H_MS, 0.0002),
        _funding_row(d100 + 2 * _8H_MS, 0.0004),
    ]
    paged = _PagedFunding(rows, limit=2)
    bb = BybitFunding(fetch=paged.fetch, sleep=lambda _: None)
    daily = bb.funding_history("BTCUSDT", start_ms=0)  # tiny synthetic epoch-days < the 2020 floor
    # day 100 = SUM of its three 8h rates; day 99 = its single rate.
    assert abs(daily[100] - (0.0001 + 0.0002 + 0.0004)) < 1e-12
    assert abs(daily[99] - (-0.0003)) < 1e-12
    # It actually paginated (>1 fetch) and walked endTime backward (strictly decreasing cursors after page 1).
    assert len(paged.calls) >= 2


def test_funding_history_empty_page_stops_loop():
    paged = _PagedFunding([], limit=200)
    bb = BybitFunding(fetch=paged.fetch, sleep=lambda _: None)
    assert bb.funding_history("NEWUSDT") == {}
    assert len(paged.calls) == 1  # one fetch, empty -> stop


def test_funding_history_survivorship_delisted_symbol_returns_history():
    # A delisted coin (e.g. LUNAUSDT) still has funding rows on Bybit — the adapter must return them.
    d = 50 * _DAY_MS
    paged = _PagedFunding([_funding_row(d, 0.001), _funding_row(d + _8H_MS, 0.002)], limit=200)
    bb = BybitFunding(fetch=paged.fetch, sleep=lambda _: None)
    daily = bb.funding_history("LUNAUSDT", start_ms=0)  # tiny synthetic epoch-days < the 2020 floor
    assert abs(daily[50] - 0.003) < 1e-12


def test_funding_history_respects_start_ms_floor():
    # Rows older than start_ms must be dropped (we paginate down to the floor, not forever).
    d = 10 * _DAY_MS
    rows = [_funding_row(d, 0.001), _funding_row(20 * _DAY_MS, 0.002)]
    paged = _PagedFunding(rows, limit=200)
    bb = BybitFunding(fetch=paged.fetch, sleep=lambda _: None)
    daily = bb.funding_history("BTCUSDT", start_ms=15 * _DAY_MS)
    assert 20 in daily and 10 not in daily  # only the row at/after the floor survives


def test_default_floor_is_2020_01():
    # documents the default backstop floor (≈ 2020-01-01 in ms).
    import datetime as dt
    assert int(
        dt.datetime(2020, 1, 1, tzinfo=dt.UTC).timestamp() * 1000) == BYBIT_FUNDING_FLOOR_MS


def test_active_universe_parses_instruments_info():
    def fetch(url: str) -> dict:
        assert "instruments-info" in url and "category=linear" in url
        return {"retCode": 0, "result": {"list": [
            {"symbol": "BTCUSDT", "status": "Trading"},
            {"symbol": "ETHUSDT", "status": "Trading"},
            {"symbol": "BTCUSDC", "status": "Trading"},   # non-USDT quote — excluded
            {"symbol": "BTC-01JAN", "status": "Trading"},  # dated future — excluded
        ]}}
    bb = BybitFunding(fetch=fetch)
    assert bb.active_universe() == ["BTCUSDT", "ETHUSDT"]


def test_ever_listed_universe_unions_active_and_delisted():
    def fetch(url: str) -> dict:
        return {"retCode": 0, "result": {"list": [{"symbol": "BTCUSDT", "status": "Trading"}]}}

    def fetch_html(url: str) -> str:
        # public.bybit.com/trading/ directory listing (Apache-style autoindex).
        return """
        <html><body><pre>
        <a href="BTCUSDT/">BTCUSDT/</a>
        <a href="LUNAUSDT/">LUNAUSDT/</a>
        <a href="ETHUSD/">ETHUSD/</a>   inverse — excluded
        <a href="../">../</a>
        </pre></body></html>
        """
    bb = BybitFunding(fetch=fetch)
    universe = bb.ever_listed_universe(fetch_html)
    # active ∪ delisted, USDT perps only, sorted, deduped.
    assert universe == ["BTCUSDT", "LUNAUSDT"]


def test_funding_history_retries_on_rate_limit_retcode_then_succeeds():
    # Under higher ingest concurrency Bybit may return a rate-limit retCode (10006). The adapter must back
    # off (injected no-op sleep — no real wait) and retry, then return the data.
    d = 30 * _DAY_MS
    real = _PagedFunding([_funding_row(d, 0.001)], limit=200)
    state = {"n": 0}

    def flaky(url: str) -> dict:
        state["n"] += 1
        if state["n"] <= 2:                          # first two calls are rate-limited
            return {"retCode": 10006, "retMsg": "too many visits", "result": {}}
        return real.fetch(url)

    waited: list[float] = []
    bb = BybitFunding(fetch=flaky, sleep=waited.append)
    daily = bb.funding_history("BTCUSDT", start_ms=0)
    assert abs(daily[30] - 0.001) < 1e-12           # data eventually returned
    assert state["n"] >= 3                           # it retried past the 2 rate-limited responses
    assert waited[:2] == [0.5, 1.0]                  # exponential backoff (no real sleep)
