"""Bybit USDT-perp funding adapter — the carry signal, SURVIVORSHIP-FREE, off Bybit v5 public market data.

Why Bybit: live execution will be Bybit EU, but every edge was originally backtested on a legacy combined-book
venue. Carry is venue-sensitive (funding + universe differ), so we re-validate it on the venue we'll actually
trade. Bybit's funding/history endpoint retains DELISTED symbols (e.g. LUNAUSDT returns rows) — strictly
better than the legacy venue's REST, which purges delisted names — so we can build a truly survivorship-free
funding panel.

Three pieces (all HTTP is INJECTED so tests run with no network):
  * active_universe()                 -> the live USDT linear perps (instruments-info)
  * funding_history(symbol, ...)      -> {epoch_day: daily_funding}, daily = SUM of the day's 8h rates
                                         (3/day), paginated BACKWARD via endTime down to a floor.
  * ever_listed_universe(fetch_html)  -> active ∪ delisted (public.bybit.com/trading dir listing) USDT perps

Daily aggregation matches the existing `crypto_funding_panel` convention (the legacy combined-book ingest
also summed the day's 8h stamps into one daily carry number), so the same XsFundingReplay / carry
accounting consumes either panel.
"""
from __future__ import annotations

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

from fxhnt.application.bybit_concurrency import with_rate_limit_backoff

_API = "https://api.bybit.com"
_PUBLIC_TRADING = "https://public.bybit.com/trading/"
INTERVALS_PER_DAY = 3  # Bybit USDT perps settle funding every 8h (3/day) — matches the legacy venue's constant.
_PAGE_LIMIT = 200      # max rows/page on funding/history

# Backstop floor for the backward pagination: ≈ 2020-01-01 UTC in ms. Bybit funding goes back to ~2022; the
# 2020 floor is a generous lower bound so the loop terminates even if a symbol somehow has older rows.
BYBIT_FUNDING_FLOOR_MS = int(_dt.datetime(2020, 1, 1, tzinfo=_dt.UTC).timestamp() * 1000)

# Bybit USDT linear perp symbols are exactly "<BASE>USDT" (no quote-asset variation, no dated suffix). A
# strict match excludes USDC perps and dated futures (BTC-01JAN…) that also appear in instruments-info /
# the public trading dir.
_USDT_PERP_RE = re.compile(r"^[A-Z0-9]+USDT$")


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 _http_get_text(url: str, tries: int = 4) -> str:
    """Default HTML fetcher for the public.bybit.com directory listing."""
    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 r.read().decode("utf-8", "replace")
        except Exception:  # noqa: BLE001 — transient HTTP; retry then re-raise
            if a == tries - 1:
                raise
            time.sleep(2 * (a + 1))
    raise RuntimeError("unreachable")


class BybitFunding:
    def __init__(self, *, fetch: Callable[[str], Any] = _http_get_json,
                 sleep: Callable[[float], None] = time.sleep,
                 backoff_base: float = 0.5, backoff_tries: int = 5) -> None:
        # fetch(url) -> parsed JSON dict (injected for tests). Wrapped with rate-limit backoff so that under
        # higher ingest concurrency a Bybit 429 / rate-limit retCode (10006/10018) is retried with
        # exponential backoff (base, 2·base, …) instead of failing — `sleep` is the (injectable) backoff
        # sleeper too, so tests passing a no-op sleep never actually wait.
        self._fetch = with_rate_limit_backoff(
            fetch, sleep=sleep, base=backoff_base, tries=backoff_tries)
        self._sleep = sleep          # throttle between pages in production; no-op-able in tests

    # -- universe -----------------------------------------------------------------------------------
    def active_universe(self) -> list[str]:
        """Live USDT linear perps from /v5/market/instruments-info (category=linear). Sorted, USDT-only."""
        url = f"{_API}/v5/market/instruments-info?category=linear&limit=1000"
        data = self._fetch(url)
        syms = [str(it["symbol"]) for it in data.get("result", {}).get("list", [])]
        return sorted({s for s in syms if _USDT_PERP_RE.match(s)})

    def ever_listed_universe(self, fetch_html: Callable[[str], str] = _http_get_text) -> list[str]:
        """SURVIVORSHIP-FREE symbol set: active (instruments-info) ∪ delisted (public.bybit.com/trading
        directory enumeration). The funding API retains each name's history, so unioning the delisted dir
        names with the active set yields every ever-listed USDT perp. USDT perps only; sorted; deduped."""
        active = set(self.active_universe())
        html = fetch_html(_PUBLIC_TRADING)
        # Apache-style autoindex: <a href="BTCUSDT/">. Take each dir name, strip the trailing slash.
        dir_syms = {m.rstrip("/") for m in re.findall(r'href="([^"]+/)"', html)}
        delisted = {s for s in dir_syms if _USDT_PERP_RE.match(s)}
        return sorted(active | delisted)

    # -- funding history (paginated backward; survivorship-free) -------------------------------------
    def funding_history(self, symbol: str, *, start_ms: int | None = None) -> dict[int, float]:
        """{epoch_day: daily_funding} for `symbol`, where daily_funding = SUM of that UTC day's 8h
        fundingRate values (3/day aggregated to daily, matching the crypto_funding_panel convention).

        Paginates BACKWARD via `endTime`: fetch newest page, then set endTime = oldest row's ts − 1 and
        loop, stopping on the first empty page or when the oldest row falls below `start_ms`
        (default BYBIT_FUNDING_FLOOR_MS ≈ 2020-01). SURVIVORSHIP-FREE: a delisted symbol still returns
        its historical rows."""
        floor = BYBIT_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)
        while True:
            url = (f"{_API}/v5/market/funding/history?category=linear&symbol={symbol}"
                   f"&limit={_PAGE_LIMIT}")
            if end_ms is not None:
                url += f"&endTime={end_ms}"
            data = self._fetch(url)
            rows = data.get("result", {}).get("list", [])
            if not rows:
                break
            oldest_ms = end_ms if end_ms is not None else None
            for row in rows:
                ts_ms = int(row["fundingRateTimestamp"])
                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 // 86_400_000] += float(row["fundingRate"])
            if oldest_ms is None or oldest_ms <= floor:
                break                          # walked back to the floor → done
            next_end = oldest_ms - 1
            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)
