"""urllib CrossVenueFundingClient — Binance / Bybit / Hyperliquid public perp APIs (no key, no capital).
Faithful to foxhunt scripts/surfer/cross_venue_funding.py (binance()/bybit()/hyperliquid()/spreads(),
lines 55-98): each venue's funding is normalized to a DAILY rate, then exposed per coin per venue.

Per-venue daily-funding normalization (must match the original exactly):
  * Binance: lastFundingRate × (24 / fundingIntervalHours)   (default 8h interval → ×3)   [lines 56-62]
  * Bybit:   fundingRate × 3   (8h interval → ×3)                                          [lines 65-68]
  * Hyperliquid: funding × 24  (1h interval → ×24)                                         [lines 71-78]

`base(sym)` strips a trailing USDT/USDC quote so the same coin maps across venues (Hyperliquid already
uses the bare coin name). snapshot() returns (funding, volume) — each {coin: {venue: rate|vol}} from ONE
fetch — over the union of coins present on any fetched venue (venues that fail to fetch are simply absent,
as in spreads()).

Retries with backoff; mirrors the original `get()` helper (GET + POST for Hyperliquid)."""
from __future__ import annotations

import json
import time
import urllib.request
from typing import Any

_BINANCE = "https://fapi.binance.com"
_BYBIT = "https://api.bybit.com"
_HL = "https://api.hyperliquid.xyz"

_VENUES = ("Binance", "Bybit", "HL")


def _base(sym: str) -> str:
    """Strip a trailing USDT/USDC quote (original base(), lines 48-52)."""
    for q in ("USDT", "USDC"):
        if sym.endswith(q):
            return sym[: -len(q)]
    return sym


class CrossVenueFundingClient:
    def _get(self, url: str, post: dict[str, Any] | None = None, tries: int = 4) -> Any:
        data = json.dumps(post).encode() if post is not None else None
        headers = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
        if post is not None:
            headers["Content-Type"] = "application/json"
        for a in range(tries):
            try:
                req = urllib.request.Request(url, data=data, headers=headers)
                return json.loads(urllib.request.urlopen(req, timeout=30).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 _binance(self) -> dict[str, tuple[float, float]]:
        """{coin: (daily_funding, 24h_quote_volume)} (original binance(), lines 55-62)."""
        fund = {x["symbol"]: float(x["lastFundingRate"]) for x in self._get(f"{_BINANCE}/fapi/v1/premiumIndex")}
        try:
            itv = {
                x["symbol"]: float(x.get("fundingIntervalHours", 8))
                for x in self._get(f"{_BINANCE}/fapi/v1/fundingInfo")
            }
        except Exception:  # noqa: BLE001 — fundingInfo optional; default 8h interval
            itv = {}
        vol = {x["symbol"]: float(x["quoteVolume"]) for x in self._get(f"{_BINANCE}/fapi/v1/ticker/24hr")}
        return {
            _base(s): (f * (24.0 / itv.get(s, 8)), vol.get(s, 0.0))
            for s, f in fund.items()
            if s.endswith("USDT")
        }

    def _bybit(self) -> dict[str, tuple[float, float]]:
        """{coin: (daily_funding, 24h_turnover)} (original bybit(), lines 65-68)."""
        r = self._get(f"{_BYBIT}/v5/market/tickers?category=linear")["result"]["list"]
        return {
            _base(x["symbol"]): (float(x["fundingRate"]) * 3, float(x.get("turnover24h", 0.0)))
            for x in r
            if x["symbol"].endswith("USDT") and x.get("fundingRate")
        }

    def _hyperliquid(self) -> dict[str, tuple[float, float]]:
        """{coin: (daily_funding, 24h_notional_volume)} (original hyperliquid(), lines 71-78)."""
        r = self._get(f"{_HL}/info", post={"type": "metaAndAssetCtxs"})
        meta, ctxs = r[0], r[1]
        out: dict[str, tuple[float, float]] = {}
        for u, c in zip(meta["universe"], ctxs, strict=True):
            if c.get("funding") is not None:
                out[u["name"]] = (float(c["funding"]) * 24, float(c.get("dayNtlVlm", 0.0)))
        return out

    def _fetch(self) -> dict[str, dict[str, tuple[float, float]]]:
        """{venue: {coin: (daily_funding, volume)}} — skip venues that fail (original spreads() lines 82-87)."""
        fns = {"Binance": self._binance, "Bybit": self._bybit, "HL": self._hyperliquid}
        out: dict[str, dict[str, tuple[float, float]]] = {}
        for nm, fn in fns.items():
            try:
                out[nm] = fn()
            except Exception:  # noqa: BLE001 — a venue outage must not kill the scan (original prints & skips)
                continue
        return out

    def snapshot(self) -> tuple[dict[str, dict[str, float]], dict[str, dict[str, float]]]:
        """One _fetch() pass per run → (funding_by_venue, volume_by_venue) built from the SAME response
        objects (funding + the venue's volume field together), over the union of coins on the fetched
        venues. Avoids the prior double-fetch + cross-snapshot drift of the two single-field methods."""
        v = self._fetch()
        coins = set().union(*[set(d) for d in v.values()]) if v else set()
        funding = {c: {nm: v[nm][c][0] for nm in _VENUES if nm in v and c in v[nm]} for c in coins}
        volume = {c: {nm: v[nm][c][1] for nm in _VENUES if nm in v and c in v[nm]} for c in coins}
        return funding, volume
