"""Bybit daily-kline adapter — PERP + SPOT daily close, 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's return is funding + BASIS (spot_ret − perp_ret), so re-validating carry on Bybit needs both
price legs on Bybit too.
This pulls the daily close for the perp leg (category=linear) and the spot leg (category=spot); the ingest
writes them as the `close` / `spot_close` features the carry/warehouse panels already consume.

Survivorship-free is by-symbol: the caller passes the ever-listed universe (active ∪ delisted), and a
delisted symbol that still has kline rows is pulled like any other; one that returns nothing contributes
nothing and never aborts.

All HTTP is INJECTED (`fetch(url)->dict`) so tests run with no network. Daily klines are returned NEWEST
first; we paginate BACKWARD via the `end` cursor down to a floor (~2020-01) or an empty page.
"""
from __future__ import annotations

import datetime as _dt
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
from fxhnt.application.worst_basis import worst_basis_by_day

_API = "https://api.bybit.com"
_PAGE_LIMIT = 1000     # max rows/page on kline (≈2.7yr of daily bars)
_DAY_MS = 86_400_000

# Default time-window width for the streaming 1m worst_basis fetch. Peak memory = ~one window of 1m bars
# (window_days × 1440 min × 2 legs ≈ 86k bars at 30d), independent of total history — so the FULL multi-year
# ingest fits a small (6GB) node at any worker concurrency. Windows are aligned to UTC-day boundaries so a
# UTC day is never split across two windows (each day's 1m bars are fully resident in exactly one window).
WORST_BASIS_WINDOW_DAYS = 30

# Backstop floor for the backward pagination: ≈ 2020-01-01 UTC in ms. Bybit history 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_KLINE_FLOOR_MS = int(_dt.datetime(2020, 1, 1, tzinfo=_dt.UTC).timestamp() * 1000)

# Kline row layout: [startMs, open, high, low, close, volume, turnover].
_HIGH_IX = 2
_LOW_IX = 3
_CLOSE_IX = 4
_TURNOVER_IX = 6


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")


class BybitKlines:
    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

    def daily_ohlcv(self, symbol: str, *, category: str,
                    start_ms: int | None = None) -> dict[int, dict[str, float]]:
        """{epoch_day: {"close","high","low","turnover"}} for `symbol`'s daily bars (close=row idx 4,
        high=2, low=3, turnover=6), where epoch_day = startMs // 86_400_000. `category` ∈ {"linear"
        (perp), "spot"}.

        Paginates BACKWARD via `end`: fetch newest page, then set end = oldest startMs − 1 and loop,
        stopping on the first empty page or when the oldest bar falls below `start_ms`
        (default BYBIT_KLINE_FLOOR_MS ≈ 2020-01). Dedupes across page boundaries; guards against a
        non-advancing cursor (infinite-loop guard). SURVIVORSHIP-FREE by-symbol: a delisted name that
        still returns rows is pulled like any other. `daily_close` is a thin projection of this."""
        floor = BYBIT_KLINE_FLOOR_MS if start_ms is None else int(start_ms)
        out: dict[int, dict[str, float]] = {}
        seen: set[int] = set()                 # guard duplicate startMs across page boundaries
        end_ms: int | None = None              # first page: no cursor (newest)
        while True:
            url = (f"{_API}/v5/market/kline?category={category}&symbol={symbol}"
                   f"&interval=D&limit={_PAGE_LIMIT}")
            if end_ms is not None:
                url += f"&end={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:
                start = int(row[0])
                if oldest_ms is None or start < oldest_ms:
                    oldest_ms = start
                if start < floor or start in seen:
                    continue
                seen.add(start)
                out[start // 86_400_000] = {
                    "close": float(row[_CLOSE_IX]),
                    "high": float(row[_HIGH_IX]),
                    "low": float(row[_LOW_IX]),
                    "turnover": float(row[_TURNOVER_IX]),
                }
            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 out

    def daily_close(self, symbol: str, *, category: str,
                    start_ms: int | None = None) -> dict[int, float]:
        """{epoch_day: close} for `symbol`'s daily bars (close = row index 4). A thin projection of
        `daily_ohlcv` (kept for backward-compat — same pagination/dedupe/floor/survivorship semantics)."""
        return {day: bar["close"]
                for day, bar in self.daily_ohlcv(symbol, category=category, start_ms=start_ms).items()}

    def _intraday_closes_by_day(self, symbol: str, *, category: str, start_ms: int | None,
                                interval: str,
                                end_ms_ceiling: int | None = None) -> dict[int, dict[int, float]]:
        """{epoch_day: {epoch_ms: close}} for `symbol`'s intraday bars at `interval` minutes, paginated
        BACKWARD via `end` down to `start_ms` (default the 2020 floor) / an empty page / a non-advancing
        cursor — same pagination contract as `daily_ohlcv`, just bucketed by day so the caller can process
        and DISCARD one day at a time. `category` ∈ {"linear","spot"}; close = row idx 4.

        WINDOWED fetch: when `start_ms` AND `end_ms_ceiling` are both set, this pages over ONLY the half-open
        time window [start_ms, end_ms_ceiling) — both the `start` and `end` query params are sent so the
        single fetch is bounded to that window's bars, never the whole history. `intraday_worst_basis` calls
        this per UTC-day-aligned window so peak memory is one window of 1m bars."""
        floor = BYBIT_KLINE_FLOOR_MS if start_ms is None else int(start_ms)
        out: dict[int, dict[int, float]] = {}
        seen: set[int] = set()
        # First page's `end` cursor = the window ceiling (inclusive of the last ms in the window) if windowed;
        # else None (newest). The ceiling is exclusive, so cap at ceiling−1.
        end_ms: int | None = None if end_ms_ceiling is None else int(end_ms_ceiling) - 1
        while True:
            url = (f"{_API}/v5/market/kline?category={category}&symbol={symbol}"
                   f"&interval={interval}&limit={_PAGE_LIMIT}")
            if start_ms is not None:
                url += f"&start={floor}"        # window floor → fetch is bounded below (memory-bounded)
            if end_ms is not None:
                url += f"&end={end_ms}"
            data = self._fetch(url)
            rows = data.get("result", {}).get("list", [])
            if not rows:
                break
            oldest_ms = end_ms
            for row in rows:
                start = int(row[0])
                if oldest_ms is None or start < oldest_ms:
                    oldest_ms = start
                if start < floor or start in seen:
                    continue
                seen.add(start)
                out.setdefault(start // _DAY_MS, {})[start] = float(row[_CLOSE_IX])
            if oldest_ms is None or oldest_ms <= floor:
                break
            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 out

    def intraday_worst_basis(self, symbol: str, *, start_ms: int | None = None,
                             interval: str = "1",
                             window_days: int = WORST_BASIS_WINDOW_DAYS,
                             now_ms: int | None = None) -> dict[int, float]:
        """{epoch_day: worst_basis} — the REAL intraday squeeze-tail: per UTC day, the deepest the delta-
        neutral carry leg (long spot / short perp) marked underwater intraday, from `interval`-minute
        (default 1m) perp (category=linear) AND spot (category=spot) klines, ALIGNED by bar startMs within
        each day. The per-day value reuses the legacy venue's `worst_basis_by_day` UNCHANGED — the MINIMUM over the
        day's running cumulative Σ(spot_1m_ret − perp_1m_ret) from the day's first aligned minute, capped at
        −1.0 (total leg loss) — so the two venues' worst_basis are computed by the SAME definition and are
        directly comparable. A benign day where the basis never moves against the leg ≈ 0; only a genuine
        intraday squeeze produces a materially negative value.

        ONLY days with BOTH perp AND spot 1m bars (≥2 aligned minutes) yield a value; a perp-only day (no
        spot leg) is OMITTED, so the carry accounting falls back to that day's daily-close basis — exactly
        like the legacy venue's worst_basis fallback.

        MEMORY-BOUNDED via TIME-WINDOWING: instead of loading the whole multi-year 1m history of BOTH legs
        into memory at once (the old all-at-once path → ~6GB at 12 workers → OOM on a 6GB node), the fetch is
        processed in `window_days`-wide windows (default 30) marching forward from `start_ms` (the 2020 floor
        if None) to `now_ms`. Windows are aligned to UTC-day boundaries, so each UTC day's 1m bars are FULLY
        contained in exactly one window — no day is ever split or double-counted across a boundary. For each
        window: fetch ONLY that window's perp+spot 1m bars, compute the per-day worst_basis for the window's
        days via the same `worst_basis_by_day`, accumulate the (small, one-float-per-day) RESULTS into `out`,
        then DISCARD the window's 1m bars before the next window. Peak resident memory is therefore ~one
        window of 1m bars (≈ window_days × 1440 × 2 legs), independent of total history; the output dict
        stays small. Per-day worst_basis VALUES are IDENTICAL to the all-at-once computation (windowing is a
        pure memory optimization). Survivorship-free by-symbol: a name that returns nothing yields {}."""
        floor = BYBIT_KLINE_FLOOR_MS if start_ms is None else int(start_ms)
        now = int(time.time() * 1000) if now_ms is None else int(now_ms)
        win_ms = max(1, int(window_days)) * _DAY_MS
        # Align the first window's lower edge to the UTC-day boundary at/below `floor`, so every window edge
        # lands on a day boundary and no day straddles two windows.
        win_lo = (floor // _DAY_MS) * _DAY_MS
        out: dict[int, float] = {}
        while win_lo < now:
            win_hi = win_lo + win_ms                      # half-open [win_lo, win_hi), day-aligned
            perp_by_day = self._intraday_closes_by_day(
                symbol, category="linear", start_ms=win_lo, interval=interval, end_ms_ceiling=win_hi)
            spot_by_day = self._intraday_closes_by_day(
                symbol, category="spot", start_ms=win_lo, interval=interval, end_ms_ceiling=win_hi)
            for day in sorted(set(perp_by_day) & set(spot_by_day)):
                wb = worst_basis_by_day(perp_close=perp_by_day[day], spot_close=spot_by_day[day])
                if day in wb:
                    out[day] = max(wb[day], -1.0)         # cap at total leg loss (−100%)
            # window's 1m bars go out of scope here → only `out` (one float/day) survives to the next window
            win_lo = win_hi
        return out
