"""币安只读 REST：USDT 永续 (fapi) + 欧洲期权 (eapi)。含节流与 429/418 退避。"""

from __future__ import annotations

import json
import logging
import threading
import time
from pathlib import Path
from typing import Any

import httpx

from ..types import BookLevel
from .parse import rows_to_option_contracts, safe_float

logger = logging.getLogger(__name__)

_BOOK_TTL_SEC = 3.0
_CONTRACTS_TTL_SEC = 180.0
_MIN_EAPI_INTERVAL_SEC = 0.25
_DEFAULT_429_COOLDOWN_SEC = 15.0
_DEFAULT_418_COOLDOWN_SEC = 120.0


class EapiCooldownError(RuntimeError):
    """eapi 限流/封禁冷却中。"""


class BinanceRestClient:
    def __init__(
        self,
        *,
        fapi_base: str = "https://fapi.binance.com",
        eapi_base: str = "https://eapi.binance.com",
        timeout: float = 15.0,
        proxy: str | None = None,
        cache_dir: str | Path | None = None,
    ) -> None:
        self.fapi_base = fapi_base.rstrip("/")
        self.eapi_base = eapi_base.rstrip("/")
        self.proxy = (proxy or "").strip() or None
        headers = {"Accept": "application/json", "User-Agent": "eth-hedge-sim/0.3"}
        self._fapi = httpx.Client(
            base_url=self.fapi_base,
            timeout=timeout,
            proxy=self.proxy,
            headers=headers,
            trust_env=False,
        )
        self._eapi = httpx.Client(
            base_url=self.eapi_base,
            timeout=timeout,
            proxy=self.proxy,
            headers=headers,
            trust_env=False,
        )
        self._exchange_info: dict[str, Any] | None = None
        self._lock = threading.Lock()
        self._eapi_cool_until = 0.0
        self._eapi_last_at = 0.0
        self._book_ttl: dict[str, tuple[list[BookLevel], list[BookLevel], int | None, float]] = {}
        self._contracts_ttl: dict[str, tuple[float, list[dict[str, Any]]]] = {}
        root = Path(cache_dir) if cache_dir else Path(__file__).resolve().parents[3] / "data"
        root.mkdir(parents=True, exist_ok=True)
        self._exchange_info_path = root / "binance_eapi_exchangeInfo.json"

    def close(self) -> None:
        self._fapi.close()
        self._eapi.close()

    def _eapi_cooling(self) -> bool:
        return time.monotonic() < self._eapi_cool_until

    def _mark_eapi_ban(self, status_code: int, retry_after: float | None = None) -> None:
        if status_code == 418:
            wait = _DEFAULT_418_COOLDOWN_SEC
        else:
            wait = float(retry_after) if retry_after and retry_after > 0 else _DEFAULT_429_COOLDOWN_SEC
            wait = max(wait, _DEFAULT_429_COOLDOWN_SEC)
        self._eapi_cool_until = time.monotonic() + wait
        logger.warning("binance eapi HTTP %s, cooldown %.0fs", status_code, wait)

    def _throttle_eapi(self) -> None:
        now = time.monotonic()
        if now < self._eapi_cool_until:
            raise EapiCooldownError(
                f"eapi cooldown {self._eapi_cool_until - now:.1f}s left"
            )
        gap = now - self._eapi_last_at
        if gap < _MIN_EAPI_INTERVAL_SEC:
            time.sleep(_MIN_EAPI_INTERVAL_SEC - gap)
        self._eapi_last_at = time.monotonic()

    def _get_json(
        self,
        client: httpx.Client,
        path: str,
        params: dict[str, Any] | None = None,
        *,
        is_eapi: bool = False,
    ) -> Any:
        with self._lock:
            if is_eapi:
                self._throttle_eapi()
            r = client.get(path, params=params or {})
            if r.status_code in (418, 429):
                if is_eapi:
                    ra = safe_float(r.headers.get("Retry-After"))
                    self._mark_eapi_ban(r.status_code, ra)
                r.raise_for_status()
            r.raise_for_status()
            return r.json()

    def _load_exchange_info_disk(self) -> dict[str, Any] | None:
        try:
            if not self._exchange_info_path.is_file():
                return None
            body = json.loads(self._exchange_info_path.read_text(encoding="utf-8"))
            return body if isinstance(body, dict) else None
        except Exception:
            return None

    def _save_exchange_info_disk(self, body: dict[str, Any]) -> None:
        try:
            self._exchange_info_path.write_text(
                json.dumps(body, ensure_ascii=False), encoding="utf-8"
            )
        except Exception as e:
            logger.debug("save exchangeInfo cache failed: %s", e)

    def fetch_option_exchange_info(self) -> dict[str, Any]:
        if self._exchange_info is not None:
            return self._exchange_info
        try:
            body = self._get_json(self._eapi, "/eapi/v1/exchangeInfo", is_eapi=True)
            if isinstance(body, dict) and body:
                self._exchange_info = body
                self._save_exchange_info_disk(body)
                return body
        except Exception as e:
            logger.warning("binance exchangeInfo fetch failed: %s", e)
        disk = self._load_exchange_info_disk()
        if disk:
            logger.warning("binance exchangeInfo using disk cache")
            self._exchange_info = disk
            return disk
        self._exchange_info = {}
        return self._exchange_info

    def fetch_option_instruments(self, underlying: str) -> list[dict[str, Any]]:
        """underlying 如 ETH / ETHUSDT。误传 OKX family（ETH-USD_UM）时映射到 ETHUSDT。"""
        info = self.fetch_option_exchange_info()
        rows = info.get("optionSymbols") or info.get("symbols") or []
        want = (underlying or "ETHUSDT").strip().upper()
        if "USD_UM" in want or want in ("ETH-USD", "ETH-USDT", "ETHUSD"):
            want = "ETHUSDT"
        eth_mode = want in ("ETH", "ETHUSDT") or (
            want.startswith("ETH") and "-" not in want
        )
        out: list[dict[str, Any]] = []
        for row in rows:
            if not isinstance(row, dict):
                continue
            u = str(row.get("underlying") or row.get("underlyingAsset") or "").upper()
            sym = str(row.get("symbol") or "").upper()
            if eth_mode:
                if sym.startswith("ETH-") and u.startswith("ETH"):
                    out.append(row)
                continue
            base = want.replace("USDT", "") if want.endswith("USDT") else want
            if u == want or u == base or sym.startswith(f"{base}-"):
                out.append(row)
        return out

    def list_option_contracts(self, family: str) -> list[dict[str, Any]]:
        key = (family or "").strip().upper() or "ETHUSDT"
        now = time.monotonic()
        hit = self._contracts_ttl.get(key)
        if hit and now - hit[0] < _CONTRACTS_TTL_SEC:
            return hit[1]
        contracts = rows_to_option_contracts(self.fetch_option_instruments(family))
        if contracts:
            self._contracts_ttl[key] = (now, contracts)
        return contracts

    def fetch_index(self, underlying: str) -> float | None:
        """期权指数：underlying=ETHUSDT。"""
        u = (underlying or "ETHUSDT").strip().upper()
        if not u.endswith("USDT") and u.isalpha():
            u = f"{u}USDT"
        try:
            body = self._get_json(
                self._eapi, "/eapi/v1/index", {"underlying": u}, is_eapi=True
            )
            if isinstance(body, dict):
                px = safe_float(body.get("indexPrice") or body.get("price"))
                if px:
                    return px
        except Exception:
            pass
        return self.fetch_mark_perp(u if u.endswith("USDT") else "ETHUSDT")

    def fetch_mark_perp(self, symbol: str) -> float | None:
        body = self._get_json(
            self._fapi,
            "/fapi/v1/premiumIndex",
            {"symbol": (symbol or "ETHUSDT").upper()},
            is_eapi=False,
        )
        if isinstance(body, dict):
            return safe_float(body.get("markPrice")) or safe_float(body.get("indexPrice"))
        return None

    def fetch_mark_option(self, symbol: str) -> float | None:
        try:
            body = self._get_json(
                self._eapi, "/eapi/v1/mark", {"symbol": symbol}, is_eapi=True
            )
        except Exception:
            return None
        if isinstance(body, list) and body:
            return safe_float(body[0].get("markPrice"))
        if isinstance(body, dict):
            return safe_float(body.get("markPrice"))
        return None

    def fetch_mark(self, inst_id: str) -> float | None:
        from .parse import is_option_symbol

        if is_option_symbol(inst_id):
            return self.fetch_mark_option(inst_id)
        return self.fetch_mark_perp(inst_id)

    def fetch_option_ticker(self, symbol: str) -> dict[str, Any] | None:
        try:
            body = self._get_json(
                self._eapi, "/eapi/v1/ticker", {"symbol": symbol}, is_eapi=True
            )
        except Exception:
            return None
        if isinstance(body, list) and body:
            row = body[0]
            return row if isinstance(row, dict) else None
        if isinstance(body, dict):
            return body
        return None

    def fetch_books(
        self, inst_id: str, sz: int = 5
    ) -> tuple[list[BookLevel], list[BookLevel], int | None]:
        """期权路径永不向外抛限流异常；失败返回缓存或空盘。"""
        from .parse import is_option_symbol

        if is_option_symbol(inst_id):
            now = time.monotonic()
            cached = self._book_ttl.get(inst_id)
            if cached and now - cached[3] < _BOOK_TTL_SEC:
                return cached[0], cached[1], cached[2]
            if self._eapi_cooling():
                if cached:
                    return cached[0], cached[1], cached[2]
                return [], [], None

            limit = 10 if int(sz) < 10 else min(int(sz), 100)
            bids: list[BookLevel] = []
            asks: list[BookLevel] = []
            ts_ms = None
            try:
                body = self._get_json(
                    self._eapi,
                    "/eapi/v1/depth",
                    {"symbol": inst_id, "limit": limit},
                    is_eapi=True,
                )
                if isinstance(body, dict):
                    ts = safe_float(body.get("T") or body.get("E") or body.get("time"))
                    ts_ms = int(ts) if ts is not None else None
                    bids = _levels(body.get("bids") or body.get("b") or [])
                    asks = _levels(body.get("asks") or body.get("a") or [])
            except Exception as e:
                logger.debug("binance option depth %s: %s", inst_id, e)
                if cached:
                    return cached[0], cached[1], cached[2]
                # 冷却中不要再打 ticker
                if self._eapi_cooling():
                    return [], [], None

            if (not bids or not asks) and not self._eapi_cooling():
                tick = self.fetch_option_ticker(inst_id)
                if tick:
                    bid = safe_float(tick.get("bidPrice") or tick.get("b"))
                    ask = safe_float(tick.get("askPrice") or tick.get("a"))
                    bid_sz = safe_float(tick.get("bidQty") or tick.get("B")) or 1.0
                    ask_sz = safe_float(tick.get("askQty") or tick.get("A")) or 1.0
                    ts = safe_float(tick.get("time") or tick.get("E") or tick.get("T"))
                    ts_ms = int(ts) if ts is not None else ts_ms
                    if bid is not None and bid > 0 and not bids:
                        bids = [BookLevel(px=bid, sz=bid_sz)]
                    if ask is not None and ask > 0 and not asks:
                        asks = [BookLevel(px=ask, sz=ask_sz)]

            if bids or asks:
                self._book_ttl[inst_id] = (bids, asks, ts_ms, time.monotonic())
            return bids, asks, ts_ms

        limit = max(5, min(int(sz), 20))
        body = self._get_json(
            self._fapi,
            "/fapi/v1/depth",
            {"symbol": inst_id.upper(), "limit": limit},
            is_eapi=False,
        )
        if not isinstance(body, dict):
            return [], [], None
        ts = safe_float(body.get("T") or body.get("E") or body.get("time"))
        ts_ms = int(ts) if ts is not None else None
        return (
            _levels(body.get("bids") or []),
            _levels(body.get("asks") or []),
            ts_ms,
        )


def _levels(raw: list[Any]) -> list[BookLevel]:
    out: list[BookLevel] = []
    for item in raw:
        if not isinstance(item, (list, tuple)) or len(item) < 2:
            continue
        px = safe_float(item[0])
        sz = safe_float(item[1])
        if px is None or sz is None or px <= 0 or sz <= 0:
            continue
        out.append(BookLevel(px=px, sz=sz))
    return out
