"""Bybit execution adapter (design §4.1) — ccxt-backed; shape mirrors `adapters/broker/ibkr.py`. Owns ALL
exchange I/O for the testnet real-paper leg; the application/domain never see ccxt.

Safe by construction: the ccxt exchange factory is INJECTED so tests run against a fake (no network), and
`set_sandbox_mode(testnet)` keeps testnet the default. The `api_secret` is passed straight into the exchange
config and never retained on the adapter, so it can never leak via repr/str/logs. Every ccxt call is wrapped:
errors surface as a typed `BrokerError` (the Task-8 reconcile asset turns that into a structured gap row,
never a daemon crash).

`Fill` is the stable fill DTO consumed by the reconcile/record assets (Task 8).
"""
from __future__ import annotations

from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

from fxhnt.application.bybit_testnet_execution import InstrumentLimit


class BrokerError(Exception):
    """A ccxt / exchange error wrapped so callers never see (or log) raw ccxt internals."""


@dataclass(frozen=True, slots=True)
class Fill:
    """One realized testnet fill. `qty` is the (positive) filled base quantity; `side` ("BUY"/"SELL") carries
    the direction; `avg_price` is the average fill price; `ts` is the exchange timestamp (ms epoch) or None."""

    symbol: str
    side: str
    qty: float
    avg_price: float
    fee: float
    ts: int | None
    order_id: str


class BybitExecution:
    """Implements the testnet execution port over ccxt.bybit."""

    name = "bybit"

    def __init__(self, api_key: str, api_secret: str, *, testnet: bool = True,
                 exchange_factory: Callable[[dict[str, Any]], Any] | None = None) -> None:
        if exchange_factory is None:
            import ccxt

            exchange_factory = ccxt.bybit
        self._x = exchange_factory({
            "apiKey": api_key, "secret": api_secret, "enableRateLimit": True,
            "options": {"defaultType": "swap"},
        })
        # ccxt sandbox = Bybit testnet (api-testnet.bybit.com). Passed the bool so testnet=False also flips it.
        self._x.set_sandbox_mode(testnet)
        self._testnet = testnet

    def __repr__(self) -> str:  # never echo the secret (it isn't even retained, but be explicit)
        return f"BybitExecution(name={self.name!r}, testnet={self._testnet})"

    def equity(self) -> float:
        """Unified-account total USDT (the testnet book's mark-to-market equity)."""
        bal = self._call(self._x.fetch_balance)
        total = (bal.get("total", {}) or {}).get("USDT")
        if total is None:
            total = (bal.get("USDT", {}) or {}).get("total")
        return float(total or 0.0)

    def positions(self) -> dict[str, float]:
        """Signed base qty per symbol (long > 0, short < 0); flat/zero contracts are dropped."""
        rows = self._call(self._x.fetch_positions)
        out: dict[str, float] = {}
        for p in rows:
            contracts = float(p.get("contracts") or 0.0)
            side = p.get("side")
            if contracts == 0.0 or side not in ("long", "short"):
                continue
            symbol = (p.get("info") or {}).get("symbol") or p.get("symbol")
            out[symbol] = contracts if side == "long" else -contracts
        return out

    def funding_since(self, since_ms: int) -> float:
        """Net realized funding in USDT since `since_ms` (epoch-ms): sum of the account's funding payments
        (received > 0, paid < 0). Bybit perps settle funding periodically; this is the funding component of
        the executed book's return (several 4-edge sleeves harvest it). Pages through ccxt (limit 200,
        advancing `since` past the last row's timestamp) so a multi-day window is never truncated to one page."""
        total = 0.0
        cursor = int(since_ms)
        page = 200
        while True:
            rows = self._call(self._x.fetch_funding_history, None, cursor, page) or []
            if not rows:
                break
            total += sum(float(r.get("amount") or 0.0) for r in rows)
            if len(rows) < page:
                break                                            # short page => no more data
            last_ts = rows[-1].get("timestamp")
            if last_ts is None:
                break
            nxt = int(last_ts) + 1                                # exclusive advance => no double-count
            if nxt <= cursor:
                break                                            # no forward progress => stop
            cursor = nxt
        return float(total)

    def instrument_limits(self) -> dict[str, InstrumentLimit]:
        """Exchange filters per symbol from `load_markets`: minOrderQty / qtyStep / minNotional."""
        markets = self._call(self._x.load_markets)
        out: dict[str, InstrumentLimit] = {}
        for m in markets.values():
            lim = m.get("limits", {}) or {}
            prec = m.get("precision", {}) or {}
            amount = lim.get("amount", {}) or {}
            cost = lim.get("cost", {}) or {}
            out[m["id"]] = InstrumentLimit(
                min_order_qty=float(amount.get("min") or prec.get("amount") or 0.0),
                qty_step=float(prec.get("amount") or amount.get("min") or 0.0),
                min_notional=float(cost.get("min") or 0.0),
            )
        return out

    def mid(self, symbol: str) -> float | None:
        """Best bid/ask midpoint from the order book; None on an empty/one-sided (thin testnet) book — the
        sentinel the record asset flags as `low_confidence` so it's excluded from the slippage verdict."""
        ob = self._call(self._x.fetch_order_book, symbol)
        bids = ob.get("bids") or []
        asks = ob.get("asks") or []
        if not bids or not asks:
            return None
        return (float(bids[0][0]) + float(asks[0][0])) / 2.0

    def open_orders(self, symbol: str | None = None) -> list[dict[str, Any]]:
        """Open (resting / unfilled) orders for `symbol` (all symbols when None) — the pre-place idempotency
        guard (design §4.3): a record-failed retry must not double-execute, so the reconcile aborts when any
        order is already resting on a run symbol. Wrapped like every other ccxt call (typed BrokerError)."""
        return self._call(self._x.fetch_open_orders, symbol) or []

    def place_market(self, symbol: str, signed_qty: float, *, reduce_only: bool = False) -> Fill:
        """Place a MARKET order for `signed_qty` (>0 buy, <0 sell) and return the realized `Fill`."""
        side = "buy" if signed_qty > 0 else "sell"
        amount = abs(signed_qty)
        r = self._call(self._x.create_order, symbol, "market", side, amount,
                       None, {"reduceOnly": reduce_only})
        return Fill(
            symbol=symbol,
            side=side.upper(),
            qty=float(r.get("filled") if r.get("filled") is not None else amount),
            avg_price=float(r.get("average") or r.get("price") or 0.0),
            fee=float((r.get("fee") or {}).get("cost") or 0.0),
            ts=r.get("timestamp"),
            order_id=str(r.get("id") or ""),
        )

    def _call(self, fn: Callable[..., Any], *args: Any, **kwargs: Any) -> Any:
        """Run a ccxt call, wrapping any failure in BrokerError (never leak the raw ccxt exception type)."""
        try:
            return fn(*args, **kwargs)
        except BrokerError:
            raise
        except Exception as e:  # noqa: BLE001 — ccxt raises many types; normalize to one typed error
            raise BrokerError(f"{fn.__name__} failed: {e}") from e
