"""Binance USDⓈ-M perp exchange adapter (ccxt binanceusdm). Owns ALL exchange I/O. Key production concerns
baked in: a rate-limit governor (2400 weight/min per IP, 1200 orders/min per account; back off on 429, never
hit the 418 ban), idempotent clientOrderId, exchange-filter parsing, and testnet via ccxt sandbox mode."""
from __future__ import annotations

import hashlib
import logging
import time

from fxhnt.ports.crypto_exchange import (
    ChildOrder,
    CryptoAccount,
    Fill,
    OrderBook,
    PerpPosition,
    SymbolFilter,
)

_log = logging.getLogger(__name__)
_MAX_WEIGHT_PER_MIN = 2400
_MAX_ORDERS_PER_MIN = 1200


def parse_filter(symbol: str, market: dict) -> SymbolFilter:
    lim = market.get("limits", {})
    prec = market.get("precision", {})
    return SymbolFilter(
        symbol=symbol,
        step_size=float(prec.get("amount") or lim.get("amount", {}).get("min") or 0.0),
        tick_size=float(prec.get("price") or 0.0),
        min_notional=float((lim.get("cost", {}) or {}).get("min") or 5.0),
    )


def client_id(rebalance_id: str, symbol: str, leg: str) -> str:
    """Deterministic, <=36 chars (Binance clientOrderId limit). Retry-safe: same inputs -> same id."""
    h = hashlib.sha1(f"{rebalance_id}:{symbol}:{leg}".encode()).hexdigest()[:24]
    return f"fxhnt{h}"


class RateBudget:
    """Conservative per-minute governor. Orders are counted locally; weight is updated from the
    X-MBX-USED-WEIGHT response header the adapter reads after each call.
    The constructor accepts the effective limit directly (caller applies any safety margin).
    A 60-second rolling window resets the order count so long-running processes never
    permanently exhaust the budget."""
    _WINDOW_S = 60.0

    def __init__(self, max_orders: int = _MAX_ORDERS_PER_MIN,
                 max_weight: int = _MAX_WEIGHT_PER_MIN) -> None:
        self._max_orders = max_orders
        self._max_weight = max_weight
        self._orders = 0
        self._used_weight = 0
        self._window_start: float | None = None   # lazily initialised on first try_order call

    def try_order(self) -> bool:
        now = time.time()
        if self._window_start is None:
            self._window_start = now
        elif now - self._window_start >= self._WINDOW_S:
            # New minute window — reset the per-minute order counter
            self._orders = 0
            self._window_start = now
        if self._orders >= self._max_orders:
            return False
        self._orders += 1
        return True

    def note_used_weight(self, used: int) -> None:
        self._used_weight = used

    def weight_ok(self) -> bool:
        return self._used_weight < self._max_weight


class BinanceUsdmExchange:
    """Implements the CryptoExchange Protocol over ccxt binanceusdm."""
    name = "binanceusdm"

    def __init__(self, api_key: str, api_secret: str, *, testnet: bool = True) -> None:
        import ccxt
        self._x = ccxt.binanceusdm({
            "apiKey": api_key, "secret": api_secret, "enableRateLimit": True,
            "options": {"defaultType": "future"},
        })
        if testnet:
            self._x.set_sandbox_mode(True)
        self._budget = RateBudget(max_orders=int(_MAX_ORDERS_PER_MIN * 0.9),
                                  max_weight=int(_MAX_WEIGHT_PER_MIN * 0.9))

    def _backoff(self, fn, *a, **kw):
        for attempt in range(5):
            try:
                out = fn(*a, **kw)
                used = (self._x.last_response_headers or {}).get("X-MBX-USED-WEIGHT-1M")
                if used:
                    self._budget.note_used_weight(int(used))
                return out
            except Exception as e:                       # noqa: BLE001 — ccxt raises many types; classify by msg
                msg = str(e).lower()
                if "429" in msg or "rate limit" in msg or "-1003" in msg:
                    retry_after = None
                    hdrs = getattr(self._x, "last_response_headers", None) or {}
                    for k, v in hdrs.items():
                        if k.lower() == "retry-after":
                            try:
                                retry_after = float(v)
                            except (TypeError, ValueError):
                                retry_after = None
                            break
                    wait = min(retry_after if retry_after is not None else 2 ** attempt, 60.0)
                    _log.warning("rate limited; backoff %ss (attempt %d)", wait, attempt + 1)
                    time.sleep(wait)
                    continue
                raise
        raise RuntimeError("rate-limit backoff exhausted")

    def exchange_info(self) -> dict[str, SymbolFilter]:
        markets = self._backoff(self._x.load_markets)
        out: dict[str, SymbolFilter] = {}
        for m in markets.values():
            if m.get("swap") and m.get("quote") == "USDT" and m.get("active"):
                out[m["id"]] = parse_filter(m["id"], m)
        return out

    def order_book(self, symbol: str) -> OrderBook:
        ob = self._backoff(self._x.fetch_order_book, symbol, 5)
        bid, bq = (ob["bids"][0] if ob["bids"] else (0.0, 0.0))
        ask, aq = (ob["asks"][0] if ob["asks"] else (0.0, 0.0))
        return OrderBook(symbol=symbol, bid=float(bid), ask=float(ask), bid_qty=float(bq), ask_qty=float(aq))

    def positions(self) -> list[PerpPosition]:
        ps = self._backoff(self._x.fetch_positions)
        out = []
        for p in ps:
            contracts = float(p.get("contracts") or 0.0)
            side = p.get("side")
            if contracts == 0.0 or side not in ("long", "short"):
                continue
            amt = contracts if side == "long" else -contracts
            out.append(PerpPosition(symbol=p["info"].get("symbol", p["symbol"]), qty=amt,
                                    entry_price=float(p.get("entryPrice") or 0.0),
                                    leverage=int(float(p.get("leverage") or 1))))
        return out

    def account(self) -> CryptoAccount:
        bal = self._backoff(self._x.fetch_balance)
        nlv = float(bal["info"].get("totalMarginBalance") or bal.get("USDT", {}).get("total") or 0.0)
        avail = float(bal["info"].get("availableBalance") or bal.get("USDT", {}).get("free") or 0.0)
        pos = {p.symbol: p.qty for p in self.positions()}
        return CryptoAccount(nlv=nlv, available_balance=avail, positions=pos)

    def set_leverage(self, symbol: str, leverage: int) -> None:
        try:
            self._backoff(self._x.set_leverage, leverage, symbol)
        except Exception as e:                            # noqa: BLE001 — idempotent; already-set is fine
            _log.debug("set_leverage %s=%d: %s", symbol, leverage, e)

    def ensure_one_way_mode(self) -> None:
        try:
            self._backoff(self._x.set_position_mode, False)   # hedged=False -> one-way
        except Exception as e:                            # noqa: BLE001 — already one-way raises; ignore
            _log.debug("set_position_mode one-way: %s", e)

    def place(self, order: ChildOrder) -> Fill:
        if not self._budget.try_order() or not self._budget.weight_ok():
            _log.warning("rate budget exhausted; skipping %s", order.client_id)
            return Fill(order.client_id, order.symbol, order.side, 0.0, 0.0, 0.0, "REJECTED")
        params = {"newClientOrderId": order.client_id, "reduceOnly": order.reduce_only}
        if order.order_type == "LIMIT":
            params["timeInForce"] = order.time_in_force   # "GTX" post-only or "IOC"
        try:
            r = self._backoff(self._x.create_order, order.symbol, order.order_type.lower(), order.side.lower(),
                              order.qty, order.price, params)
        except Exception as e:                            # noqa: BLE001 — log + report REJECTED, never crash batch
            _log.warning("place %s rejected: %s", order.client_id, e)
            return Fill(order.client_id, order.symbol, order.side, 0.0, 0.0, 0.0, "REJECTED")
        filled = float(r.get("filled") or 0.0)
        status = "FILLED" if filled >= order.qty - 1e-12 else ("PARTIAL" if filled > 0 else
                 (r.get("status") or "CANCELED").upper())
        return Fill(order.client_id, order.symbol, order.side, filled, float(r.get("average") or 0.0),
                    float((r.get("fee") or {}).get("cost") or 0.0), status)

    def cancel(self, symbol: str, client_id: str) -> None:
        try:
            self._backoff(self._x.cancel_order, client_id, symbol, {"origClientOrderId": client_id})
        except Exception as e:                            # noqa: BLE001 — already gone is fine
            _log.debug("cancel %s: %s", client_id, e)

    def order_status(self, symbol: str, client_id: str) -> Fill:
        """Fetch the authoritative post-cancel fill state by clientOrderId.
        After a cancel completes the exchange reports the FINAL filled quantity — use this
        (not the stale place-response) to size the IOC remainder and avoid over-filling."""
        try:
            r = self._backoff(self._x.fetch_order, None, symbol, {"origClientOrderId": client_id})
        except Exception as e:  # noqa: BLE001 — unknown/already-gone -> treat as nothing filled (safe)
            _log.debug("order_status %s: %s", client_id, e)
            return Fill(client_id, symbol, "", 0.0, 0.0, 0.0, "CANCELED")
        filled = float(r.get("filled") or 0.0)
        side = (r.get("side") or "").upper()
        status = "FILLED" if filled > 0 and (r.get("status") or "").lower() == "closed" else \
                 ("PARTIAL" if filled > 0 else (r.get("status") or "CANCELED").upper())
        return Fill(client_id, symbol, side, filled, float(r.get("average") or 0.0),
                    float((r.get("fee") or {}).get("cost") or 0.0), status)
