"""IBKR broker adapter — implements the Broker port via ib_async. The application/domain never see
ib_async; swapping brokers is just another adapter here.

Bakes in hard-won lessons: paper detection via the DU* account prefix; whole-share market orders
(the API rejects fractional, error 10243); a single managed connection reused across a rebalance
(reconnecting per order causes clientId churn). ib_async is an optional dependency (extra: ibkr).
"""
from __future__ import annotations

import logging
import math
from dataclasses import dataclass, field
from types import TracebackType
from typing import Any

from fxhnt.ports.broker import AccountState, Order

log = logging.getLogger(__name__)

_SUMMARY_TAGS = ("NetLiquidation", "TotalCashValue", "GrossPositionValue")

# IBKR market-data type: 1=Live, 2=Frozen, 3=Delayed, 4=Delayed-Frozen. Paper accounts lack live
# subscriptions for most UCITS lines, so the UCITS price fetch requests DELAYED data (3).
_MKTDATA_DELAYED = 3


class ContractResolutionError(RuntimeError):
    """An IBKR contract failed to resolve (empty qualifyContracts). Raised by place_order so the caller
    SKIPS that sleeve — never places a wrong or unqualified order."""


def _usable_price(x: object) -> float | None:
    """A positive, non-NaN number as a float, else None (a delayed-data field can be NaN or absent)."""
    if isinstance(x, (int, float)) and not math.isnan(float(x)) and float(x) > 0.0:
        return float(x)
    return None


def _ticker_price(ticker: Any) -> float | None:
    """Extract a usable price from an ib_async Ticker, tolerating NaN/empty delayed-data fields. Prefers the
    library's blended marketPrice(), then last, close, then the bid/ask midpoint."""
    mp = getattr(ticker, "marketPrice", None)
    candidates = [mp() if callable(mp) else mp,
                  getattr(ticker, "last", None), getattr(ticker, "close", None)]
    for c in candidates:
        px = _usable_price(c)
        if px is not None:
            return px
    bid, ask = _usable_price(getattr(ticker, "bid", None)), _usable_price(getattr(ticker, "ask", None))
    if bid is not None and ask is not None:
        return (bid + ask) / 2.0
    return None


@dataclass
class AccountSnapshot:
    """The REAL DU-paper-account state at a rebalance (D2.1) — captured additively alongside the existing
    intended-book return basis, for the `ibkr_account_nav` table."""
    nlv: float
    cash: float
    gross: float
    positions: dict[str, float] = field(default_factory=dict)


class IbkrBroker:
    name = "ibkr"
    supports_fractional = False   # IBKR rejects fractional market orders (error 10243) — whole shares only

    def __init__(self, host: str = "127.0.0.1", port: int = 4002, client_id: int = 17, timeout: int = 15) -> None:
        self._host, self._port, self._cid, self._timeout = host, port, client_id, timeout
        self._ib: Any = None
        self._trades: list[Any] = []   # ib-async Trade objects from place_order, popped by pop_trades()

    def _ensure(self) -> Any:
        from ib_async import IB

        if self._ib is None or not self._ib.isConnected():
            ib = IB()
            ib.connect(self._host, self._port, clientId=self._cid, timeout=self._timeout)
            self._ib = ib
        return self._ib

    def account_state(self) -> AccountState:
        ib = self._ensure()
        accts = ib.managedAccounts()
        acct = accts[0] if accts else "?"
        summ = {v.tag: float(v.value) for v in ib.accountSummary() if v.tag in _SUMMARY_TAGS}
        positions = {p.contract.symbol: float(p.position) for p in ib.positions()}
        return AccountState(
            nlv=summ.get("NetLiquidation", 0.0),
            cash=summ.get("TotalCashValue", 0.0),
            positions=positions,
            is_paper=acct.startswith("DU"),
            gross_position_value=summ.get("GrossPositionValue"),
        )

    def _resolve_contract(self, ib: Any, symbol: str, exchange: str, currency: str,
                          sec_id_type: str | None, sec_id: str | None) -> Any | None:
        """Build the IBKR contract and qualify it. With an ISIN (sec_id_type/sec_id) it builds a
        Contract(secIdType=ISIN, secId=..., exchange=..., currency=...) with NO symbol — the ISIN is the
        unambiguous key and the map's cosmetic ticker may differ from the USD line's real symbol (IEF's USD
        line is CBU0, GLD's is IGLN), so pinning `symbol` would fight the ISIN. Probed live on DU9600528
        (2026-07-15): SMART+ISIN returns 0 for these LSE-listed UCITS lines; LSEETF+ISIN+USD resolves each.
        The US path stays a plain Stock(symbol, SMART, USD). Returns the qualified contract, or None."""
        from ib_async import Contract, Stock

        if sec_id_type and sec_id:
            contract = Contract(secType="STK", exchange=exchange or "LSEETF",
                                currency=currency, secIdType=sec_id_type, secId=sec_id)
        else:
            contract = Stock(symbol, exchange or "SMART", currency)
        qualified = ib.qualifyContracts(contract)
        return qualified[0] if qualified else None

    def market_price(self, symbol: str, exchange: str = "SMART", currency: str = "USD",
                     sec_id_type: str | None = None, sec_id: str | None = None) -> float | None:
        """Qualify the contract and return a REAL market price in `currency` via DELAYED market data (paper
        accounts lack live subscriptions). Returns None — caller SKIPS the sleeve — if the contract does not
        resolve or no usable price comes back. Used to size the UCITS route from the real UCITS price."""
        # A raise anywhere in the IB round-trip (connection blip, or an odd error on a thin/new line) must be
        # downgraded to a per-sleeve skip (None), NOT propagated — one bad sleeve can't wedge the rebalance.
        try:
            ib = self._ensure()
            contract = self._resolve_contract(ib, symbol, exchange, currency, sec_id_type, sec_id)
        except Exception:
            log.error("IBKR contract resolution RAISED for symbol=%r (secId=%r) — SKIPPING sleeve.",
                      symbol, sec_id, exc_info=True)
            return None
        if contract is None:
            log.error("IBKR contract did NOT resolve for price: symbol=%r exchange=%r currency=%r "
                      "secId=%r — SKIPPING sleeve (no price, no order).", symbol, exchange, currency, sec_id)
            return None
        # 1) delayed live snapshot — fast, works when the delayed stream is free.
        px = None
        try:
            ib.reqMarketDataType(_MKTDATA_DELAYED)
            ticker = ib.reqMktData(contract, "", True, False)   # snapshot=True
            ib.sleep(2)
            px = _ticker_price(ticker)
        except Exception:
            log.warning("IBKR delayed market-data raised for symbol=%r — trying historical close.", symbol)
        # 2) FALLBACK: historical last close. Conflict-free — no live-session/subscription contention. Some LSE
        #    lines return Error 10197 ("competing live session") / 354 on the live stream (probed 2026-07-15:
        #    Euronext-primary DBMF and the physical-gold ETC IGLN) yet give a clean daily close here. A prior
        #    close is fine for paper sizing (delayed-vs-live divergence already accepted on paper).
        if px is None or not (px > 0):
            try:
                bars = ib.reqHistoricalData(contract, "", "5 D", "1 day", "MIDPOINT", useRTH=True, formatDate=1)
                if bars and bars[-1].close and bars[-1].close > 0:
                    px = float(bars[-1].close)
                    log.info("IBKR: no live price for symbol=%r — using historical last close %.4f.", symbol, px)
            except Exception:
                log.error("IBKR historical-price fallback RAISED for symbol=%r (secId=%r) — SKIPPING sleeve.",
                          symbol, sec_id, exc_info=True)
        if px is None or not (px > 0):
            log.error("IBKR returned no usable price (live + historical) for symbol=%r (secId=%r) — "
                      "SKIPPING sleeve.", symbol, sec_id)
            return None
        return px

    def historical_daily_volume(self, *, ticker: str, isin: str, exchange: str = "LSEETF",
                                currency: str = "USD", days: int = 90) -> dict[str, float]:
        """Real on-exchange daily volume for a UCITS line (reqHistoricalData TRADES, probe-verified
        2026-07-16) — keyed by ISO date string, feeding `snapshot_ibkr_volumes`. Integration-only (needs a
        live ib-gateway); never raises into the CronJob's per-line loop — resolve-fail/no-data/any error all
        downgrade to an empty dict (log + continue), same discipline as `market_price`."""
        try:
            ib = self._ensure()
            contract = self._resolve_contract(ib, ticker, exchange, currency, "ISIN", isin)
            if contract is None:
                log.error("IBKR contract did NOT resolve for historical volume: ticker=%r isin=%r "
                          "exchange=%r currency=%r — returning empty.", ticker, isin, exchange, currency)
                return {}
            bars = ib.reqHistoricalData(contract, "", f"{days} D", "1 day", "TRADES",
                                        useRTH=True, formatDate=1)
            out: dict[str, float] = {}
            for bar in bars:
                if bar.volume in (None, -1):
                    continue
                d = bar.date
                out[d.isoformat() if hasattr(d, "isoformat") else str(d)] = float(bar.volume)
            return out
        except Exception:
            log.error("IBKR historical volume fetch RAISED for ticker=%r (isin=%r) — returning empty.",
                      ticker, isin, exc_info=True)
            return {}

    def resolve_symbol_info(self, *, ticker: str, isin: str, exchange: str = "LSEETF",
                            currency: str = "USD") -> dict[str, str] | None:
        """READ-ONLY: qualify a UCITS line by ISIN and report the identity IBKR actually assigns it — the
        `symbol`, `localSymbol`, and `conId` that `positions()`/`accountSummary()` will report a HOLDING under.
        Used by the `probe-ucits-symbols` CLI to detect where a config `ticker` (cosmetic) diverges from what
        IBKR reports (e.g. IEF's `CBU0` -> canonical `CSBGU0`), so `_expected_book_symbols` can key the NLV
        stray-check on the REPORTED symbol instead of the display ticker. Returns None on resolve failure
        (logged), never raises — same discipline as `historical_daily_volume`."""
        try:
            ib = self._ensure()
            contract = self._resolve_contract(ib, ticker, exchange, currency, "ISIN", isin)
            if contract is None:
                log.error("IBKR contract did NOT resolve for symbol probe: ticker=%r isin=%r", ticker, isin)
                return None
            return {"ticker": ticker, "isin": isin,
                    "ibkr_symbol": str(getattr(contract, "symbol", "") or ""),
                    "local_symbol": str(getattr(contract, "localSymbol", "") or ""),
                    "con_id": str(getattr(contract, "conId", "") or "")}
        except Exception:
            log.error("IBKR symbol probe RAISED for ticker=%r (isin=%r) — returning None.",
                      ticker, isin, exc_info=True)
            return None

    def place_order(self, order: Order) -> str:
        from ib_async import MarketOrder

        ib = self._ensure()
        contract = self._resolve_contract(ib, order.symbol, order.exchange, order.currency,
                                          order.sec_id_type, order.sec_id)
        if contract is None:
            log.error("IBKR contract did NOT resolve for order %s %s (exchange=%r currency=%r secId=%r) "
                      "— SKIPPING sleeve, NO order placed.", order.side, order.symbol, order.exchange,
                      order.currency, order.sec_id)
            raise ContractResolutionError(
                f"contract not resolved for {order.symbol} (exchange={order.exchange}, "
                f"currency={order.currency}, secId={order.sec_id})")
        # Route via SMART, keeping the resolved conId. The UCITS contract resolves on LSEETF (see
        # _resolve_contract), but placing DIRECT to LSEETF trips IBKR's precautionary "direct-routed orders"
        # restriction -> Error 10311 -> order discarded. SMART is in every UCITS line's valid-exchange set
        # (probed 2026-07-15), so smart-routing the qualified conId fills without the direct-route penalty.
        # conId pins the exact instrument, so SMART cannot mis-route to the wrong (e.g. US grey-market) line.
        contract.exchange = "SMART"
        # Bug fix (Error 478, live 2026-07-20): the ISIN-qualified UCITS contract can retain a `secId`/
        # `localSymbol` that CONFLICTS with the conId IBKR pinned (e.g. requested localSymbol CBU0 vs the
        # canonical CSBGU0 for ISIN IE00B3VWN518) -> "Parameters in request conflicts with contract parameters"
        # -> the order is REJECTED and the leg gets 0 fills. The conId alone is unambiguous, so strip the
        # ambiguous identity fields after resolution; IBKR resolves purely on conId + SMART. Guarded getattr:
        # these fields may be absent on the plain-Stock US path.
        conid = getattr(contract, "conId", 0)
        if conid:
            for _f in ("secId", "secIdType", "localSymbol"):
                if getattr(contract, _f, None):
                    setattr(contract, _f, "")
        market_order = MarketOrder(order.side, int(round(order.quantity)))  # whole shares
        # Bug fix (Error 10349, live 2026-07-20): a MarketOrder with NO explicit TIF let the account's order
        # preset force TIF=DAY and cancel-then-reconfirm each order (a wasted round-trip; DBMF hung on
        # 'Submitted'). Set TIF=DAY explicitly so the order already matches the account preset — no override,
        # no cancel. DAY is correct for a market-hours rebalance (the run fires at the UCITS open).
        market_order.tif = "DAY"
        accts = ib.managedAccounts()
        if accts:
            market_order.account = accts[0]
        trade = ib.placeOrder(contract, market_order)
        ib.sleep(2)
        self._trades.append(trade)   # collected by pop_trades() so fills are capturable (D2.1)
        return str(trade.orderStatus.status)

    def pop_trades(self) -> list[Any]:
        """Return and clear the ib-async `Trade` objects placed since the last pop (one rebalance's worth).
        `exec_capture.capture_fills` reads each trade's `.fills` to persist the realized executions."""
        trades, self._trades = self._trades, []
        return trades

    def account_snapshot(self) -> AccountSnapshot:
        """The REAL account state right now (NLV/cash/gross + positions), reusing the same `accountSummary`/
        `positions` reads as `account_state` (D2.1 — captured additively, not the gate-relevant basis)."""
        ib = self._ensure()
        summ = {v.tag: float(v.value) for v in ib.accountSummary() if v.tag in _SUMMARY_TAGS}
        positions = {p.contract.symbol: float(p.position) for p in ib.positions()}
        return AccountSnapshot(nlv=summ.get("NetLiquidation", 0.0), cash=summ.get("TotalCashValue", 0.0),
                               gross=summ.get("GrossPositionValue", 0.0), positions=positions)

    def place_combo(self, legs: list[tuple[str, str, int]], net_limit: float) -> str:
        """Atomic XSP put-credit-spread as a BAG combo. legs = (osi_symbol, side, qty); net_limit<0 = credit.
        Requires options trading permission (account currently lacks it -> this path ships suspended)."""
        from ib_async import Bag, ComboLeg, LimitOrder, Option

        ib = self._ensure()
        combo_legs = []
        for osi, side, _qty in legs:
            opt = Option()  # qualified from the OSI symbol
            opt.localSymbol = osi
            opt.exchange = "SMART"
            cid = ib.qualifyContracts(opt)[0].conId
            # IBKR combo semantics: contracts/leg = parent totalQuantity x this ratio. For a 1:1 vertical
            # spread each leg's ratio must be 1 -- the parent order's totalQuantity (below) is already the
            # number of spreads, so a per-leg ratio=qty here would double it to qty^2.
            combo_legs.append(ComboLeg(conId=cid, ratio=1, action=side, exchange="SMART"))
        bag = Bag(symbol="XSP", exchange="SMART", currency="USD", comboLegs=combo_legs)
        total_qty = legs[0][2]
        # IBKR combo convention: per-leg actions already encode direction (e.g. SELL short put / BUY long
        # put to open; reversed to close). The PARENT bag order is always "BUY" the combo, with the limit
        # price carrying the net cash flow sign: a NEGATIVE limit = BUY the bag "for" a negative debit, i.e.
        # a net CREDIT received (opening); a POSITIVE limit = a net DEBIT paid (closing). "SELL" the bag
        # would invert every leg's already-assigned action, which is wrong here.
        order = LimitOrder("BUY", total_qty, net_limit)
        trade = ib.placeOrder(bag, order)
        ib.sleep(1)
        return str(trade.order.orderId)

    def close(self) -> None:
        if self._ib is not None and self._ib.isConnected():
            self._ib.disconnect()

    def __enter__(self) -> IbkrBroker:
        self._ensure()
        return self

    def __exit__(self, exc_type: type[BaseException] | None, exc: BaseException | None, tb: TracebackType | None) -> None:
        self.close()
