"""Deribit perp funding adapter — the Deribit leg of the cross-venue funding-carry edge.

Why this signal: Bybit perp funding runs PERSISTENTLY higher than Deribit on the liquid majors (probed:
Bybit BTC avg|f|~0.0004 vs Deribit ~0.000276 — retail-heavy Bybit pays more). A delta-neutral cross-venue
carry SHORTs the Bybit perp (collects its higher funding) / LONGs the Deribit perp (pays the lower funding)
on the SAME coin; the perp prices track the same index so price risk ~cancels and the return ≈ the funding
SPREAD (bybit_funding − deribit_funding). This adapter supplies the Deribit half of that spread.

Endpoint (HTTP is INJECTED so tests run with no network):
  GET /api/v2/public/get_funding_rate_history?instrument_name=BTC-PERPETUAL
      &start_timestamp=<ms>&end_timestamp=<ms>
    -> result[] rows {timestamp (ms), interest_1h, interest_8h, ...}. Deribit accrues funding hourly and
       reports the realized hourly funding as `interest_1h` (interest_8h is the trailing-8h figure). One row
       per hour; the API returns the rows in the requested window, oldest-first.

DAILY AGGREGATION (matches the Bybit daily convention): Bybit's `funding_history` SUMs the UTC day's three
8h fundingRate stamps into ONE daily funding number (the day's total funding cost). The equivalent realized
DAILY funding on Deribit is the SUM of that UTC day's `interest_1h` rows = the total funding accrued over the
day. We therefore aggregate `daily_funding = Σ interest_1h over the rows whose timestamp falls in the UTC day`
(epoch_day = timestamp_ms // 86_400_000). This puts both venues' funding on the same per-UTC-day total-funding
basis, so `bybit_funding − deribit_funding` is a like-for-like daily spread.

`funding_history(instrument, *, start_ms=None) -> {epoch_day: daily_funding}` paginates BACKWARD via
end_timestamp (mirrors DeribitDVOL.dvol_history's shape) down to a floor (default ≈ 2021-01, a generous
lower bound), stopping on the first empty page or once the oldest row falls below the floor.
"""
from __future__ import annotations

import datetime as _dt
import json
import time
import urllib.request
from collections import defaultdict
from collections.abc import Callable
from typing import Any

_API = "https://www.deribit.com/api/v2/public/get_funding_rate_history"
_PAGE_LIMIT = 1000     # generous per-request cap; Deribit returns the rows in the requested window
_DAY_MS = 86_400_000

# Backstop floor for the backward pagination: ≈ 2021-01-01 UTC in ms. Deribit perpetuals (BTC/ETH) trade back
# well before this; 2021-01 is a generous lower bound at which the backward walk terminates for the majors.
DERIBIT_FUNDING_FLOOR_MS = int(_dt.datetime(2021, 1, 1, tzinfo=_dt.UTC).timestamp() * 1000)


def _http_get_json(url: str, tries: int = 4) -> Any:
    """Default JSON fetcher (urllib, retry+backoff) — mirrors the other free-data adapters' `_get`."""
    for a in range(tries):
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
            with urllib.request.urlopen(req, timeout=40) as r:
                return json.loads(r.read())
        except Exception:  # noqa: BLE001 — transient HTTP; retry then re-raise
            if a == tries - 1:
                raise
            time.sleep(2 * (a + 1))
    raise RuntimeError("unreachable")


def _with_backoff(fetch: Callable[[str], Any], *, sleep: Callable[[float], None],
                  base: float, tries: int) -> Callable[[str], Any]:
    """Wrap `fetch(url)->dict` with exponential backoff on ANY transient exception (the Deribit endpoint has
    no Bybit-style retCode; a 429/5xx surfaces as a raised exception). Retries `base·2**attempt` up to
    `tries`; the final attempt re-raises. `sleep` is injectable (tests pass a no-op recorder)."""
    def wrapped(url: str) -> Any:
        for attempt in range(tries):
            try:
                return fetch(url)
            except Exception:  # noqa: BLE001 — transient HTTP; backoff then re-raise on exhaustion
                if attempt == tries - 1:
                    raise
                sleep(base * (2 ** attempt))
        raise RuntimeError("unreachable")  # pragma: no cover — loop always returns or raises
    return wrapped


class DeribitFunding:
    def __init__(self, *, fetch: Callable[[str], Any] = _http_get_json,
                 sleep: Callable[[float], None] = time.sleep,
                 clock: Callable[[], float] = time.time,
                 backoff_base: float = 0.5, backoff_tries: int = 5) -> None:
        # fetch(url) -> parsed JSON dict (injected for tests). Wrapped with exponential backoff so a transient
        # 429/5xx from Deribit is retried instead of failing — `sleep` is the (injectable) backoff sleeper
        # too, so tests passing a no-op sleep never actually wait.
        self._fetch = _with_backoff(fetch, sleep=sleep, base=backoff_base, tries=backoff_tries)
        self._sleep = sleep          # throttle between pages in production; no-op-able in tests
        # `clock` is the wall-clock seam (epoch seconds; defaults to time.time). The first page anchors its
        # end_timestamp at `now`; injecting a fixed clock makes the backward-window walk deterministic so
        # tests do not depend on the calendar date they happen to run on.
        self._clock = clock

    def funding_history(self, instrument: str, *, start_ms: int | None = None) -> dict[int, float]:
        """{epoch_day: daily_funding} for `instrument` (e.g. BTC-PERPETUAL), where daily_funding = SUM of that
        UTC day's `interest_1h` rows (the day's total realized funding — matching the Bybit daily-sum
        convention so `bybit − deribit` is a like-for-like daily spread).

        Paginates BACKWARD via end_timestamp: fetch the newest page, then set end_timestamp = oldest row's
        ts − 1 and loop, stopping on the first empty page or when the oldest row falls below `start_ms`
        (default DERIBIT_FUNDING_FLOOR_MS ≈ 2021-01). Dedupes hourly timestamps across page boundaries (a row
        is summed into its day EXACTLY once). The infinite-loop guard breaks if the cursor stops advancing."""
        floor = DERIBIT_FUNDING_FLOOR_MS if start_ms is None else int(start_ms)
        daily: dict[int, float] = defaultdict(float)
        seen: set[int] = set()                 # guard against duplicate ts across page boundaries
        end_ms: int | None = None              # first page: no cursor (newest)
        # Deribit get_funding_rate_history returns at most ~744 rows (≈31d hourly) AND 400s on windows
        # wider than ~1yr — so request a bounded ~30d window per page (720 rows, complete) and walk back.
        _PAGE_WINDOW_MS = 30 * _DAY_MS
        while True:
            now_ms = int(self._clock() * 1000)
            page_end = end_ms if end_ms is not None else now_ms
            page_start = max(floor, page_end - _PAGE_WINDOW_MS)
            if page_start >= page_end:
                break          # nothing left below the cursor (floor reached) — never emit start>=end
            url = (f"{_API}?instrument_name={instrument}&start_timestamp={page_start}"
                   f"&end_timestamp={page_end}")
            data = self._fetch(url)
            rows = data.get("result", [])
            if not rows:
                # An empty page is only the END of the walk once this window already starts AT the floor
                # (we've reached inception / the requested lower bound). A transient empty ~30d window
                # ABOVE the floor (a data gap) must NOT prematurely stop a legitimate backward walk — keep
                # walking end_timestamp back by one window past the gap.
                if page_start <= floor:
                    break
                end_ms = page_start - 1
                self._sleep(0.1)
                continue
            oldest_ms = end_ms if end_ms is not None else None
            for row in rows:
                ts_ms = int(row["timestamp"])
                if oldest_ms is None or ts_ms < oldest_ms:
                    oldest_ms = ts_ms
                if ts_ms < floor or ts_ms in seen:
                    continue
                seen.add(ts_ms)
                daily[ts_ms // _DAY_MS] += float(row["interest_1h"])
            if oldest_ms is None or oldest_ms <= floor:
                break                          # saw a row at/below the floor → walked back to inception
            next_end = oldest_ms - 1
            if next_end < floor:
                break                          # nothing left below the floor → done
            if end_ms is not None and next_end >= end_ms:
                break                          # cursor not advancing → avoid an infinite loop
            end_ms = next_end
            self._sleep(0.1)                   # gentle throttle; tests inject a no-op sleep
        return dict(daily)
