"""Bybit TRUE L1 (top-of-book) quoted-spread adapter — the REAL bid/ask the book would cross live.

WHY THIS EXISTS
---------------
The measured-cost re-audit charges the Bybit book half each coin's Corwin–Schultz spread, estimated from the
DAILY HIGH/LOW. Corwin–Schultz from daily high/low captures the intraday RANGE, not the quoted bid/ask, so on
liquid coins it MASSIVELY over-states the true L1 spread (it reported a ~144bp median per-coin spread, whereas
BTC/ETH quote spreads are ≈ 1bp). This adapter fetches the CURRENT quoted L1 spread so the re-cost can use the
REAL bid/ask — the forward-realistic cost (what we'd pay deploying NOW), not the inflated high/low proxy.

ENDPOINT (HTTP is INJECTED so tests run with NO network):
  GET https://api.bybit.com/v5/market/tickers?category=linear   (public, no auth)
    -> result.list[] of per-symbol rows carrying `symbol`, `bid1Price`, `ask1Price`, `lastPrice`. ONE request
       returns EVERY linear (USDT/USDC) perp's top-of-book — no pagination.

  real_spread_bps(coin) = (ask1 − bid1) / mid × 1e4,  mid = (bid1 + ask1) / 2.
  Symbols with missing / zero / crossed (ask < bid) quotes are SKIPPED (defensive — a single malformed row
  must never raise; newly-listed / settling symbols can omit fields).

CLOCK SEAM (mirrors DeribitDVOL / DeribitFunding): the wall clock is INJECTED (`clock: Callable[[], float] =
time.time`). The tickers endpoint is a single CURRENT snapshot (no time-windowed pagination), but the snapshot
is STAMPED with `clock()` so the caveat — a current snapshot applied across history — carries its own asof
time, and so tests can prove no bare `time.time()` leaks in (a fixed clock yields a deterministic stamp).
"""
from __future__ import annotations

import contextlib
import json
import time
import urllib.request
from collections.abc import Callable
from typing import Any

from fxhnt.application.bybit_concurrency import with_rate_limit_backoff

_API = "https://api.bybit.com"


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 spread_bps(bid1: Any, ask1: Any) -> float | None:
    """The L1 quoted spread in bps from a bid/ask pair, or None if the quote is unusable.

    spread_bps = (ask − bid) / mid × 1e4,  mid = (bid + ask) / 2. Returns None (→ caller SKIPS the symbol)
    when either side is missing / non-numeric / ≤ 0, or the book is crossed (ask < bid). A perfectly tight
    book (ask == bid) is a VALID 0.0 spread, not skipped. Pure / deterministic."""
    try:
        b = float(bid1)
        a = float(ask1)
    except (TypeError, ValueError):
        return None
    if b <= 0.0 or a <= 0.0 or a < b:
        return None
    mid = 0.5 * (b + a)
    if mid <= 0.0:
        return None
    return (a - b) / mid * 1e4


class BybitTrueSpread:
    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 rate-limit backoff so a Bybit 429 /
        # rate-limit retCode 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_rate_limit_backoff(fetch, sleep=sleep, base=backoff_base, tries=backoff_tries)
        # `clock` is the wall-clock seam (epoch seconds; defaults to time.time). The snapshot is stamped with
        # clock() so the "current snapshot applied across history" caveat carries its asof time; injecting a
        # FIXED clock makes the stamp deterministic (proving no bare time.time() leaks in).
        self._clock = clock

    def snapshot(self, *, universe: set[str] | None = None) -> dict[str, Any]:
        """The current L1 quoted-spread snapshot for every linear perp (or just `universe` if given).

        Returns `{fetched_at, spreads {symbol: spread_bps}, last_price {symbol: float}, n}`. Symbols with a
        missing / zero / crossed quote are SKIPPED defensively (a malformed row never raises). One HTTP call —
        the tickers endpoint returns the whole book at once."""
        data = self._fetch(f"{_API}/v5/market/tickers?category=linear")
        rows = data.get("result", {}).get("list", []) if isinstance(data, dict) else []
        spreads: dict[str, float] = {}
        last_price: dict[str, float] = {}
        for row in rows:
            if not isinstance(row, dict):
                continue
            sym = row.get("symbol")
            if not sym:
                continue
            if universe is not None and sym not in universe:
                continue
            sp = spread_bps(row.get("bid1Price"), row.get("ask1Price"))
            if sp is None:
                continue
            spreads[str(sym)] = sp
            with contextlib.suppress(TypeError, ValueError):
                last_price[str(sym)] = float(row.get("lastPrice"))
        return {"fetched_at": float(self._clock()), "spreads": spreads,
                "last_price": last_price, "n": len(spreads)}

    def real_spread_bps(self, *, universe: set[str] | None = None) -> dict[str, float]:
        """`{symbol: real L1 quoted spread bps}` for every linear perp (or just `universe`). The forward-cost
        input: the CURRENT quoted bid/ask, NOT the high/low Corwin–Schultz proxy. Missing/zero/crossed quotes
        are skipped."""
        return self.snapshot(universe=universe)["spreads"]
