"""BybitKlines.intraday_worst_basis — the REAL 1m worst_basis (deepest adverse INTRADAY basis per UTC day),
the override that replaces the broken additive daily high/low proxy.

The definition MATCHES Binance's `worst_basis_by_day` (reused unchanged): per day, the MINIMUM over the
day's running cumulative Σ(spot_1m_ret − perp_1m_ret) from the first aligned minute (capped at −1.0 = total
leg loss). So a benign day where the basis barely moves ≈ 0; only a genuine intraday squeeze bites.

ALL HTTP is injected/mocked — no network. The mock serves 1m klines newest-first off the `end` cursor like
Bybit, with `category` (linear=perp, spot) honoured in the URL.
"""
from __future__ import annotations

import math

from fxhnt.adapters.data.bybit_klines import BybitKlines

_MIN_MS = 60_000
_DAY_MS = 86_400_000


def _kline_1m(start_ms: int, close: float) -> list[str]:
    # [startMs, open, high, low, close, volume, turnover]; only close (idx 4) matters for worst_basis.
    return [str(start_ms), str(close), str(close + 1), str(close - 1), str(close), "1", "1"]


def _parse_qs(url: str, key: str) -> str | None:
    from urllib.parse import parse_qs, urlparse
    q = parse_qs(urlparse(url).query)
    return q[key][0] if key in q else None


class _Paged1m:
    """Mock /v5/market/kline?interval=1: serves 1m rows newest-first honouring the `end` cursor (rows with
    startMs <= end, descending, up to `limit`); empty page past the oldest -> stops the loop. `category`
    selects the perp vs spot panel."""

    def __init__(self, perp: list[list[str]], spot: list[list[str]], *, limit: int = 1000) -> None:
        self._perp = sorted(perp, key=lambda r: int(r[0]), reverse=True)
        self._spot = sorted(spot, key=lambda r: int(r[0]), reverse=True)
        self._limit = limit
        self.calls: list[dict[str, str | None]] = []

    def fetch(self, url: str) -> dict:
        end = _parse_qs(url, "end")
        category = _parse_qs(url, "category")
        interval = _parse_qs(url, "interval")
        self.calls.append({"end": end, "category": category, "interval": interval})
        rows = self._perp if category == "linear" else self._spot
        end_i = int(end) if end is not None else None
        cand = rows if end_i is None else [r for r in rows if int(r[0]) <= end_i]
        return {"retCode": 0, "result": {"list": cand[: self._limit]}}


def test_intraday_worst_basis_two_days_deepest_adverse_per_day() -> None:
    # Day 0 (epoch_day 19000): perp rises, spot falls intraday -> negative basis worst.
    # Day 1 (epoch_day 19001): both move together -> benign (worst ~0).
    d0 = 19000 * _DAY_MS
    d1 = 19001 * _DAY_MS
    perp = [
        _kline_1m(d0 + 0 * _MIN_MS, 100.0),
        _kline_1m(d0 + 1 * _MIN_MS, 105.0),   # perp +5%
        _kline_1m(d0 + 2 * _MIN_MS, 102.0),
        _kline_1m(d1 + 0 * _MIN_MS, 100.0),
        _kline_1m(d1 + 1 * _MIN_MS, 101.0),   # perp +1%
    ]
    spot = [
        _kline_1m(d0 + 0 * _MIN_MS, 100.0),
        _kline_1m(d0 + 1 * _MIN_MS, 98.0),    # spot -2% -> basis step = -2% - 5% = -7%
        _kline_1m(d0 + 2 * _MIN_MS, 100.0),
        _kline_1m(d1 + 0 * _MIN_MS, 100.0),
        _kline_1m(d1 + 1 * _MIN_MS, 101.0),   # spot +1% -> basis step = +1% - 1% = 0
    ]
    bb = BybitKlines(fetch=_Paged1m(perp, spot).fetch, sleep=lambda _: None)
    out = bb.intraday_worst_basis("BTCUSDT", start_ms=0)
    # Day 0 worst = the cumulative basis min. Step1 = (98/100-1) - (105/100-1) = -0.02 - 0.05 = -0.07.
    assert math.isclose(out[19000], -0.07, abs_tol=1e-9)
    # Day 1 worst ~ 0 (basis only ever flat/up).
    assert math.isclose(out[19001], 0.0, abs_tol=1e-9)


def test_intraday_worst_basis_worse_than_close_to_close() -> None:
    # A sharp intraday spot drop that RECOVERS by the close: worst_basis must be worse than close-to-close.
    d0 = 19000 * _DAY_MS
    perp = [
        _kline_1m(d0 + 0 * _MIN_MS, 100.0),
        _kline_1m(d0 + 1 * _MIN_MS, 100.0),
        _kline_1m(d0 + 2 * _MIN_MS, 100.0),
    ]
    spot = [
        _kline_1m(d0 + 0 * _MIN_MS, 100.0),
        _kline_1m(d0 + 1 * _MIN_MS, 70.0),    # -30% intraday squeeze
        _kline_1m(d0 + 2 * _MIN_MS, 100.0),   # fully recovers to close
    ]
    bb = BybitKlines(fetch=_Paged1m(perp, spot).fetch, sleep=lambda _: None)
    out = bb.intraday_worst_basis("BTCUSDT", start_ms=0)
    close_to_close_basis = (100.0 / 100.0 - 1.0) - (100.0 / 100.0 - 1.0)  # 0.0 (perfectly benign close)
    assert out[19000] < close_to_close_basis
    assert out[19000] < -0.25   # ~ -0.30 intraday min


def test_intraday_worst_basis_perp_only_day_absent() -> None:
    # A day with perp 1m bars but NO spot bars produces NO worst_basis (carry falls back to daily-close).
    d0 = 19000 * _DAY_MS
    perp = [_kline_1m(d0 + 0 * _MIN_MS, 100.0), _kline_1m(d0 + 1 * _MIN_MS, 99.0)]
    bb = BybitKlines(fetch=_Paged1m(perp, spot=[]).fetch, sleep=lambda _: None)
    out = bb.intraday_worst_basis("BTCUSDT", start_ms=0)
    assert out == {}


def test_intraday_worst_basis_capped_at_minus_one() -> None:
    # An extreme compounding squeeze can drive the cumulative basis below -1.0; the result is capped at -1.0.
    d0 = 19000 * _DAY_MS
    perp = [
        _kline_1m(d0 + 0 * _MIN_MS, 100.0),
        _kline_1m(d0 + 1 * _MIN_MS, 1000.0),   # perp +900% (a violent perp spike)
    ]
    spot = [
        _kline_1m(d0 + 0 * _MIN_MS, 100.0),
        _kline_1m(d0 + 1 * _MIN_MS, 50.0),     # spot -50%
    ]
    bb = BybitKlines(fetch=_Paged1m(perp, spot).fetch, sleep=lambda _: None)
    out = bb.intraday_worst_basis("BTCUSDT", start_ms=0)
    # raw step = (50/100-1) - (1000/100-1) = -0.5 - 9.0 = -9.5; capped to -1.0.
    assert out[19000] == -1.0


def test_intraday_worst_basis_uses_interval_1_and_category() -> None:
    seen_cat: list[str | None] = []
    seen_int: list[str | None] = []

    def fetch(url: str) -> dict:
        seen_cat.append(_parse_qs(url, "category"))
        seen_int.append(_parse_qs(url, "interval"))
        return {"retCode": 0, "result": {"list": []}}

    bb = BybitKlines(fetch=fetch, sleep=lambda _: None)
    bb.intraday_worst_basis("ETHUSDT", start_ms=0)
    assert "linear" in seen_cat and "spot" in seen_cat   # both legs fetched
    assert all(i == "1" for i in seen_int)               # 1m interval throughout


# --- Windowed (memory-bounded) streaming ----------------------------------------------------------


class _Windowed1m:
    """Mock /v5/market/kline?interval=1 honouring BOTH `start` (window floor) and `end` (window ceiling):
    serves rows with start <= startMs <= end, newest-first, up to `limit`. Tracks, per fetch, how many
    rows it RETURNS so a test can assert the caller never holds more than ~one window of bars at once."""

    def __init__(self, perp: list[list[str]], spot: list[list[str]], *, limit: int = 1000) -> None:
        self._perp = sorted(perp, key=lambda r: int(r[0]), reverse=True)
        self._spot = sorted(spot, key=lambda r: int(r[0]), reverse=True)
        self._limit = limit
        self.ranges: list[tuple[int | None, int | None]] = []  # (start, end) bounds per call
        self.max_rows_per_call = 0

    def fetch(self, url: str) -> dict:
        start = _parse_qs(url, "start")
        end = _parse_qs(url, "end")
        category = _parse_qs(url, "category")
        start_i = int(start) if start is not None else None
        end_i = int(end) if end is not None else None
        self.ranges.append((start_i, end_i))
        rows = self._perp if category == "linear" else self._spot
        cand = [
            r for r in rows
            if (end_i is None or int(r[0]) <= end_i) and (start_i is None or int(r[0]) >= start_i)
        ]
        page = cand[: self._limit]
        self.max_rows_per_call = max(self.max_rows_per_call, len(page))
        return {"retCode": 0, "result": {"list": page}}


def _reference_worst_basis(perp: list[list[str]], spot: list[list[str]]) -> dict[int, float]:
    """All-at-once reference: bucket EVERY 1m close by day and run worst_basis_by_day once (the pre-windowing
    semantics), to prove the windowed implementation produces identical per-day values."""
    from fxhnt.application.worst_basis import worst_basis_by_day
    perp_by_day: dict[int, dict[int, float]] = {}
    spot_by_day: dict[int, dict[int, float]] = {}
    for r in perp:
        perp_by_day.setdefault(int(r[0]) // _DAY_MS, {})[int(r[0])] = float(r[4])
    for r in spot:
        spot_by_day.setdefault(int(r[0]) // _DAY_MS, {})[int(r[0])] = float(r[4])
    out: dict[int, float] = {}
    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)
    return out


def _multi_window_series() -> tuple[list[list[str]], list[list[str]], int, int]:
    """Build a perp+spot 1m series spanning several days across MULTIPLE 30-day windows, INCLUDING a day that
    lands exactly ON a window boundary, with a real intraday squeeze on several days so values are non-trivial.
    Returns (perp, spot, base_day, window_days)."""
    window_days = 30
    base_day = 19000
    # Days chosen to straddle window boundaries: window 0 = [base_day, base_day+30), window 1 starts at
    # base_day+30 (the boundary day), etc. Include the boundary day base_day+30 explicitly.
    days = [base_day + 0, base_day + 5, base_day + 29, base_day + 30, base_day + 31, base_day + 65]
    perp: list[list[str]] = []
    spot: list[list[str]] = []
    for i, d in enumerate(days):
        d0 = d * _DAY_MS
        # perp rises across 3 minutes; spot dips mid-day then recovers -> a genuine intraday squeeze.
        perp += [
            _kline_1m(d0 + 0 * _MIN_MS, 100.0),
            _kline_1m(d0 + 1 * _MIN_MS, 100.0 + i),       # small drift, varies per day
            _kline_1m(d0 + 2 * _MIN_MS, 100.0 + i),
        ]
        spot += [
            _kline_1m(d0 + 0 * _MIN_MS, 100.0),
            _kline_1m(d0 + 1 * _MIN_MS, 100.0 - 5.0 - i),  # mid-day adverse move (varies)
            _kline_1m(d0 + 2 * _MIN_MS, 100.0),            # recovers by close
        ]
    return perp, spot, base_day, window_days


def test_intraday_worst_basis_windowed_equals_all_at_once_incl_boundary_day() -> None:
    """KEY equivalence: windowed streaming yields the SAME {epoch_day: worst_basis} as the all-at-once
    reference, including a day landing exactly on a window boundary (no day split / double-counted)."""
    perp, spot, base_day, window_days = _multi_window_series()
    bb = BybitKlines(fetch=_Windowed1m(perp, spot).fetch, sleep=lambda _: None)
    out = bb.intraday_worst_basis(
        "BTCUSDT", start_ms=base_day * _DAY_MS, window_days=window_days)
    expected = _reference_worst_basis(perp, spot)
    assert out == expected
    # boundary day (base_day+30) must be present and identical — proves it wasn't split across windows.
    assert (base_day + 30) in out
    assert math.isclose(out[base_day + 30], expected[base_day + 30], abs_tol=1e-12)


def test_intraday_worst_basis_fetch_is_windowed_bounded() -> None:
    """Memory-bound: no single fetch (and no accumulation) spans the whole history. Each fetch is bounded to
    one window (start/end set, span <= window_days), and the peak rows held is ~one window, not all days."""
    perp, spot, base_day, window_days = _multi_window_series()
    mock = _Windowed1m(perp, spot)
    bb = BybitKlines(fetch=mock.fetch, sleep=lambda _: None)
    bb.intraday_worst_basis("BTCUSDT", start_ms=base_day * _DAY_MS, window_days=window_days)
    window_ms = window_days * _DAY_MS
    # Every fetch is bounded to a window: both start and end set, span no wider than the window.
    assert mock.ranges, "expected at least one fetch"
    for start_i, end_i in mock.ranges:
        assert start_i is not None and end_i is not None, "fetch must be bounded (start AND end set)"
        assert end_i - start_i <= window_ms, "a single fetch spanned more than one window"
    # Peak rows returned by any single page is bounded by one window of 1m bars (2 legs * 1440 * window_days),
    # and in this fixture far below the all-days total — the whole history is never resident at once.
    assert mock.max_rows_per_call <= window_days * 1440
    total_rows = len(perp) + len(spot)
    assert mock.max_rows_per_call < total_rows
