"""Wrapper around ``mcp-deribit``.

Exposes the read tools Cerbero Bite needs to evaluate entry/exit and
the ``place_combo_order`` write path that submits the credit spread
atomically. Everything is converted to ``Decimal`` at the boundary so
the ``core/`` algorithms stay in their preferred numeric domain.
"""

from __future__ import annotations

import re
from datetime import UTC, datetime, timedelta
from decimal import Decimal
from typing import Any, Literal

from pydantic import BaseModel, ConfigDict

from cerbero_bite.clients._base import HttpToolClient
from cerbero_bite.clients._exceptions import McpDataAnomalyError
from cerbero_bite.core.types import PutOrCall

__all__ = [
    "ComboLegOrder",
    "ComboOrderResult",
    "DealerGammaSnapshot",
    "DeribitClient",
    "DeribitEnvironment",
    "InstrumentMeta",
]


_INSTRUMENT_RE = re.compile(
    r"^(?P<asset>[A-Z]+)-"
    r"(?P<expiry>\d{1,2}[A-Z]{3}\d{2})-"
    r"(?P<strike>\d+)-"
    r"(?P<type>[PC])$"
)


class DeribitEnvironment(BaseModel):
    """Result of the ``environment_info`` tool."""

    model_config = ConfigDict(frozen=True, extra="ignore")

    exchange: str
    environment: Literal["testnet", "mainnet"]
    source: str
    env_value: str | None
    base_url: str
    max_leverage: int | None = None


class InstrumentMeta(BaseModel):
    """Static metadata of a Deribit option instrument."""

    model_config = ConfigDict(frozen=True, extra="ignore")

    name: str
    strike: Decimal
    expiry: datetime
    option_type: PutOrCall
    open_interest: Decimal | None
    tick_size: Decimal | None
    min_trade_amount: Decimal | None


class ComboLegOrder(BaseModel):
    """One leg of a combo order request."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    instrument_name: str
    direction: Literal["buy", "sell"]
    ratio: int = 1


class ComboOrderResult(BaseModel):
    """Outcome of a ``place_combo_order`` invocation."""

    model_config = ConfigDict(frozen=True, extra="ignore")

    combo_instrument: str
    order_id: str | None
    state: str
    average_price_eth: Decimal | None
    filled_amount: Decimal | None
    raw: dict[str, Any]


class DealerGammaSnapshot(BaseModel):
    """Result of ``get_dealer_gamma_profile`` flattened to what Bite consumes."""

    model_config = ConfigDict(frozen=True, extra="ignore")

    spot_price: Decimal
    total_net_dealer_gamma: Decimal
    gamma_flip_level: Decimal | None
    strikes_analyzed: int


def _parse_instrument(name: str) -> tuple[Decimal, datetime, PutOrCall]:
    """Return ``(strike, expiry, option_type)`` parsed from a Deribit instrument."""
    match = _INSTRUMENT_RE.match(name)
    if not match:
        raise McpDataAnomalyError(
            f"deribit instrument name '{name}' does not match expected pattern"
        )
    expiry = datetime.strptime(match.group("expiry"), "%d%b%y").replace(
        hour=8, minute=0, second=0, tzinfo=UTC
    )
    return Decimal(match.group("strike")), expiry, match.group("type")  # type: ignore[return-value]


def _to_decimal(value: Any) -> Decimal | None:
    if value is None:
        return None
    return Decimal(str(value))


class DeribitClient:
    SERVICE = "deribit"

    def __init__(
        self, http: HttpToolClient, unified: HttpToolClient | None = None
    ) -> None:
        if http.service != self.SERVICE:
            raise ValueError(
                f"DeribitClient requires service '{self.SERVICE}', got '{http.service}'"
            )
        self._http = http
        # Cerbero MCP V2 moved the data tools (get_instruments,
        # get_historical, get_indicators) to the unified ``/mcp`` router;
        # they are no longer on ``/mcp-deribit``. This second client points
        # at ``/mcp`` and is only needed by those three methods — diagnostic
        # paths (e.g. ``environment_info`` for ping) leave it ``None``.
        self._unified = unified

    def _unified_client(self, tool: str) -> HttpToolClient:
        if self._unified is None:
            raise McpDataAnomalyError(
                f"unified MCP client not configured; '{tool}' lives on /mcp",
                service=self.SERVICE,
                tool=tool,
            )
        return self._unified

    # ------------------------------------------------------------------
    # Environment / health
    # ------------------------------------------------------------------

    async def environment_info(self) -> DeribitEnvironment:
        raw = await self._http.call("environment_info", {})
        return DeribitEnvironment(**raw)

    # ------------------------------------------------------------------
    # Market data
    # ------------------------------------------------------------------

    async def index_price_eth(self) -> Decimal:
        """Return the ETH spot proxy used by combo selection.

        Deribit does not expose the index price as its own MCP tool, so
        we use the ``ETH-PERPETUAL`` mark price as a proxy. On testnet
        this is good enough; on mainnet we will swap in a dedicated
        index call when the server exposes it.
        """
        raw = await self._http.call(
            "get_ticker", {"instrument_name": "ETH-PERPETUAL"}
        )
        mark = raw.get("mark_price")
        if mark is None:
            raise McpDataAnomalyError(
                "deribit ETH-PERPETUAL mark_price missing",
                service=self.SERVICE,
                tool="get_ticker",
            )
        return Decimal(str(mark))

    async def latest_dvol(
        self,
        *,
        currency: str = "ETH",
        now: datetime | None = None,
    ) -> Decimal:
        """Return the latest DVOL value for ``currency``."""
        when = (now or datetime.now(UTC)).astimezone(UTC)
        # Window starts one day back so a tick fired exactly at 00:00 UTC
        # — before Deribit has built today's 1D candle — still has
        # yesterday's close to fall back on (see candles[-1] branch).
        body = {
            "currency": currency,
            "start_date": (when.date() - timedelta(days=1)).isoformat(),
            "end_date": when.date().isoformat(),
            "resolution": "1D",
        }
        raw = await self._http.call("get_dvol", body)
        latest = raw.get("latest")
        if latest is None:
            candles = raw.get("candles") or []
            if not candles:
                raise McpDataAnomalyError(
                    "deribit DVOL response has neither 'latest' nor 'candles'",
                    service=self.SERVICE,
                    tool="get_dvol",
                )
            tail = candles[-1]
            latest = tail.get("close") if isinstance(tail, dict) else None
            if latest is None:
                raise McpDataAnomalyError(
                    "deribit DVOL last candle missing 'close'",
                    service=self.SERVICE,
                    tool="get_dvol",
                )
        return Decimal(str(latest))

    async def options_chain(
        self,
        *,
        currency: str = "ETH",
        expiry_from: datetime | None = None,
        expiry_to: datetime | None = None,
        min_open_interest: int | None = None,
        limit: int = 500,
    ) -> list[InstrumentMeta]:
        """Return option instruments matching the filters as typed metadata."""
        # MCP V2: get_instruments is unified-only (/mcp). The venue is
        # selected via ``exchange`` and each row is normalized — the native
        # symbol is ``symbol`` and Deribit-specific fields live under
        # ``native``.
        body: dict[str, Any] = {
            "exchange": self.SERVICE,
            "currency": currency,
            "kind": "option",
            "limit": limit,
        }
        if expiry_from is not None:
            body["expiry_from"] = expiry_from.date().isoformat()
        if expiry_to is not None:
            body["expiry_to"] = expiry_to.date().isoformat()
        if min_open_interest is not None:
            body["min_open_interest"] = min_open_interest

        raw = await self._unified_client("get_instruments").call(
            "get_instruments", body
        )
        instruments = raw.get("instruments") or []
        out: list[InstrumentMeta] = []
        for entry in instruments:
            if not isinstance(entry, dict):
                continue
            name = entry.get("symbol")
            if not isinstance(name, str):
                continue
            try:
                strike, expiry, option_type = _parse_instrument(name)
            except McpDataAnomalyError:
                continue
            native = entry.get("native") if isinstance(entry.get("native"), dict) else {}
            out.append(
                InstrumentMeta(
                    name=name,
                    strike=strike,
                    expiry=expiry,
                    option_type=option_type,
                    open_interest=_to_decimal(native.get("open_interest")),
                    tick_size=_to_decimal(entry.get("tick_size")),
                    min_trade_amount=_to_decimal(native.get("min_trade_amount")),
                )
            )
        return out

    async def get_tickers(self, instrument_names: list[str]) -> list[dict[str, Any]]:
        """Fetch full ticker data for up to 20 instruments at once."""
        if not instrument_names:
            return []
        if len(instrument_names) > 20:
            raise ValueError("get_tickers: max 20 instruments per call")
        raw = await self._http.call(
            "get_ticker_batch", {"instrument_names": instrument_names}
        )
        if isinstance(raw, dict) and raw.get("error"):
            raise McpDataAnomalyError(
                f"deribit get_ticker_batch error: {raw['error']}",
                service=self.SERVICE,
                tool="get_ticker_batch",
            )
        return list(raw.get("tickers") or [])

    async def orderbook_depth_top3(self, instrument_name: str) -> int:
        """Sum of size on the top-3 bid + top-3 ask levels."""
        raw = await self._http.call(
            "get_orderbook", {"instrument_name": instrument_name, "depth": 3}
        )
        bids = raw.get("bids") or []
        asks = raw.get("asks") or []

        def _sum(rows: list[Any]) -> Decimal:
            total = Decimal("0")
            for row in rows[:3]:
                if isinstance(row, list | tuple) and len(row) >= 2:
                    total += Decimal(str(row[1]))
                elif isinstance(row, dict):
                    size = row.get("amount") or row.get("size") or 0
                    total += Decimal(str(size))
            return total

        return int(_sum(bids) + _sum(asks))

    async def historical_close(
        self,
        *,
        instrument: str,
        start: datetime,
        end: datetime,
        resolution: str = "1D",
    ) -> Decimal | None:
        """Return the close of the first OHLC candle in [start, end].

        Used to fetch the 30-day-ago ETH spot for the directional bias
        in :func:`compute_bias`. Returns ``None`` when the chain has no
        data in the window.
        """
        # MCP V2: get_historical is unified-only (/mcp); it takes
        # ``exchange`` + lowercase ``interval`` (e.g. "1d", "1h") instead of
        # the old per-exchange ``resolution``.
        raw = await self._unified_client("get_historical").call(
            "get_historical",
            {
                "exchange": self.SERVICE,
                "instrument": instrument,
                "start_date": start.date().isoformat(),
                "end_date": end.date().isoformat(),
                "interval": resolution.lower(),
            },
        )
        candles = (raw or {}).get("candles") or []
        for entry in candles:
            if isinstance(entry, dict) and entry.get("close") is not None:
                return Decimal(str(entry["close"]))
        return None

    async def dealer_gamma_profile(
        self,
        currency: str,
        *,
        expiry_from: datetime | None = None,
        expiry_to: datetime | None = None,
        top_n_strikes: int = 50,
    ) -> DealerGammaSnapshot:
        """Return the aggregated dealer net gamma snapshot for ``currency``.

        Long-gamma regime (``total_net_dealer_gamma > 0``) is associated
        with vol-suppressing dealer hedging — the entry filter §2.8 uses
        this signal to avoid selling premium during short-gamma regimes
        (vol-amplifying dealer flow).
        """
        body: dict[str, Any] = {
            "currency": currency.upper(),
            "top_n_strikes": top_n_strikes,
        }
        if expiry_from is not None:
            body["expiry_from"] = expiry_from.date().isoformat()
        if expiry_to is not None:
            body["expiry_to"] = expiry_to.date().isoformat()
        raw = await self._http.call("get_dealer_gamma_profile", body)
        if not isinstance(raw, dict):
            raise McpDataAnomalyError(
                "dealer_gamma_profile: unexpected response shape",
                service=self.SERVICE,
                tool="get_dealer_gamma_profile",
            )
        spot = raw.get("spot_price")
        total = raw.get("total_net_dealer_gamma")
        if spot is None or total is None:
            raise McpDataAnomalyError(
                "dealer_gamma_profile: missing spot_price or total",
                service=self.SERVICE,
                tool="get_dealer_gamma_profile",
            )
        return DealerGammaSnapshot(
            spot_price=Decimal(str(spot)),
            total_net_dealer_gamma=Decimal(str(total)),
            gamma_flip_level=_to_decimal(raw.get("gamma_flip_level")),
            strikes_analyzed=int(raw.get("strikes_analyzed") or 0),
        )

    async def dealer_gamma_profile_eth(
        self,
        *,
        expiry_from: datetime | None = None,
        expiry_to: datetime | None = None,
        top_n_strikes: int = 50,
    ) -> DealerGammaSnapshot:
        """Backwards-compatible alias of :py:meth:`dealer_gamma_profile`."""
        return await self.dealer_gamma_profile(
            "ETH",
            expiry_from=expiry_from,
            expiry_to=expiry_to,
            top_n_strikes=top_n_strikes,
        )

    async def realized_vol(
        self,
        currency: str,
        *,
        windows: tuple[int, ...] = (14, 30),
    ) -> dict[str, Decimal | None]:
        """Annualised realised vol for ``currency`` plus IV-RV spread.

        Returns ``{"rv_14d", "rv_30d", "iv_minus_rv_30d", "iv_current"}``
        (``None`` for any missing field). Pure read-only — no side
        effects on the engine.
        """
        raw = await self._http.call(
            "get_realized_vol",
            {"currency": currency.upper(), "windows": list(windows)},
        )
        if not isinstance(raw, dict):
            return {}
        rv = raw.get("realized_vol_pct") or {}
        spread = raw.get("iv_minus_rv_pct") or {}
        return {
            "rv_14d": _to_decimal(rv.get("14d")),
            "rv_30d": _to_decimal(rv.get("30d")),
            "iv_current": _to_decimal(raw.get("iv_current_pct")),
            "iv_minus_rv_30d": _to_decimal(spread.get("30d")),
            "iv_minus_rv_14d": _to_decimal(spread.get("14d")),
        }

    async def spot_perp_price(self, asset: str) -> Decimal:
        """Mark price of ``<ASSET>-PERPETUAL`` (cheap proxy for spot)."""
        instrument = f"{asset.upper()}-PERPETUAL"
        raw = await self._http.call("get_ticker", {"instrument": instrument})
        if not isinstance(raw, dict):
            raise McpDataAnomalyError(
                f"get_ticker: unexpected shape for {instrument}",
                service=self.SERVICE,
                tool="get_ticker",
            )
        mark = raw.get("mark_price") or raw.get("last_price")
        if mark is None:
            raise McpDataAnomalyError(
                f"get_ticker: missing mark_price for {instrument}",
                service=self.SERVICE,
                tool="get_ticker",
            )
        return Decimal(str(mark))

    async def adx_14(
        self,
        *,
        instrument: str = "ETH-PERPETUAL",
        start: datetime,
        end: datetime,
        resolution: str = "1h",
    ) -> Decimal | None:
        """Return the most recent ADX(14) value, or ``None`` when missing."""
        # MCP V2: indicators are computed by the unified ``get_indicators``
        # tool (/mcp), which replaced the per-exchange
        # ``get_technical_indicators``. Body takes ``exchange`` + lowercase
        # ``interval``; the response nests each indicator under
        # ``indicators`` (ADX as ``{"adx": <float>, "+di":…, "-di":…}``).
        raw = await self._unified_client("get_indicators").call(
            "get_indicators",
            {
                "exchange": self.SERVICE,
                "instrument": instrument,
                "indicators": ["adx"],
                "start_date": start.date().isoformat(),
                "end_date": end.date().isoformat(),
                "interval": resolution.lower(),
            },
        )
        if not isinstance(raw, dict):
            return None
        indicators = raw.get("indicators")
        if not isinstance(indicators, dict):
            return None
        adx_block = indicators.get("adx")
        if isinstance(adx_block, dict):
            value = adx_block.get("adx")
            return None if value is None else Decimal(str(value))
        # Tolerate a flattened scalar shape just in case.
        if adx_block is not None and not isinstance(adx_block, list):
            return Decimal(str(adx_block))
        return None

    async def get_account_summary(self, currency: str = "USDC") -> dict[str, Any]:
        result: Any = await self._http.call(
            "get_account_summary", {"currency": currency}
        )
        return result if isinstance(result, dict) else {}

    async def get_positions(self, currency: str = "USDC") -> list[dict[str, Any]]:
        raw = await self._http.call("get_positions", {"currency": currency})
        if isinstance(raw, list):
            return raw
        # Server may also wrap the list under a key — defensive only.
        return list(raw.get("positions") or [])  # pragma: no cover

    # ------------------------------------------------------------------
    # Execution
    # ------------------------------------------------------------------

    async def place_combo_order(
        self,
        *,
        legs: list[ComboLegOrder],
        side: Literal["buy", "sell"],
        n_contracts: int,
        limit_price_eth: Decimal | None = None,
        order_type: Literal["limit", "market"] = "limit",
        label: str | None = None,
    ) -> ComboOrderResult:
        """Submit a combo order atomically."""
        if len(legs) < 2:
            raise ValueError("place_combo_order requires at least 2 legs")
        if n_contracts <= 0:
            raise ValueError("place_combo_order: n_contracts must be > 0")
        if order_type == "limit" and limit_price_eth is None:
            raise ValueError("place_combo_order: limit price required for type=limit")

        body: dict[str, Any] = {
            "legs": [leg.model_dump() for leg in legs],
            "side": side,
            "amount": n_contracts,
            "type": order_type,
        }
        if limit_price_eth is not None:
            body["price"] = float(limit_price_eth)
        if label is not None:
            body["label"] = label

        raw = await self._http.call("place_combo_order", body)
        if not isinstance(raw, dict):
            raise McpDataAnomalyError(
                "place_combo_order: server returned non-object",
                service=self.SERVICE,
                tool="place_combo_order",
            )
        combo_instrument = raw.get("combo_instrument")
        if not isinstance(combo_instrument, str):
            raise McpDataAnomalyError(
                "place_combo_order: missing 'combo_instrument' in response",
                service=self.SERVICE,
                tool="place_combo_order",
            )
        return ComboOrderResult(
            combo_instrument=combo_instrument,
            order_id=raw.get("order_id"),
            state=str(raw.get("state") or "unknown"),
            average_price_eth=_to_decimal(raw.get("average_price")),
            filled_amount=_to_decimal(raw.get("filled_amount")),
            raw=raw,
        )

    async def cancel_order(self, order_id: str) -> dict[str, Any]:
        result: Any = await self._http.call("cancel_order", {"order_id": order_id})
        return result if isinstance(result, dict) else {}
