"""Execution use-case — runs a multi-strategy Book live: net all sleeves into per-market targets,
reconcile against the broker, and place orders through the gates — WHOLE shares, or FRACTIONAL shares
when the broker advertises `supports_fractional` (no current implementation does — IBKR is the sole
Broker and is whole-share-only). Depends only on ports (DataProvider, Broker), injected by the caller.
The safety gates are hard-won from live paper trading.
"""
from __future__ import annotations

from typing import Any

from pydantic import BaseModel, Field

from fxhnt.config import Settings
from fxhnt.domain.portfolio import Book, compute_target_weights
from fxhnt.ports.broker import Broker, Order
from fxhnt.ports.data import DataProvider

# IBKR order statuses that represent a REAL fill this run. A market order at the UCITS open fills within the
# `place_order` sleep, so the terminal status is 'Filled'. Everything else — 'Cancelled' (Error 478/10349),
# 'Submitted'/'PreSubmitted'/'PendingSubmit' (accepted but not yet filled), 'Inactive' — is NOT a fill and
# marks the run degraded. Compared case-insensitively; 'PartiallyFilled' also counts (some shares executed).
_FILL_STATUSES = {"filled", "partiallyfilled"}


def _is_fill(status: str) -> bool:
    return str(status).strip().lower() in _FILL_STATUSES


class PlannedOrder(BaseModel):
    symbol: str
    side: str
    quantity: float
    est_value: float


class RebalancePlan(BaseModel):
    book: str
    nlv: float
    target_weights: dict[str, float] = Field(default_factory=dict)
    orders: list[PlannedOrder] = Field(default_factory=list)
    executed: bool = False
    blocked: bool = False
    notes: list[str] = Field(default_factory=list)
    rebalance_id: str = ""                                     # caller-assigned; "" = unset (D2.1)
    # Raw broker trade objects (e.g. ib-async Trade) placed this run, when the broker exposes `pop_trades()`
    # — read by `exec_capture.capture_fills` to persist the REAL fills (D2.1). Empty for any broker that
    # doesn't support trade collection (e.g. the test FakeBroker / a fractional broker) or on a dry run.
    trades: list[Any] = Field(default_factory=list)


class ExecutionService:
    def __init__(self, data: DataProvider, broker: Broker, settings: Settings) -> None:
        self._data = data
        self._broker = broker
        self._s = settings

    def rebalance(self, book: Book, *, execute: bool = False) -> RebalancePlan:
        """Rebalance a research Book: fetch prices, compute per-market target weights, then plan+place."""
        prices = {m: self._data.fetch(m) for m in book.markets()}
        targets = compute_target_weights(book, prices)  # {Market: signed weight}
        sized = {m.symbol: (float(w), float(prices[m].close[-1])) for m, w in targets.items()}
        return self._plan(book.name, sized, book.max_gross_leverage, execute=execute)

    def rebalance_weights(self, name: str, weights: dict[str, float], prices: dict[str, float], *,
                          max_gross: float = 1.5, execute: bool = False,
                          contract_specs: dict[str, dict[str, str]] | None = None,
                          sizing_capital: float | None = None) -> RebalancePlan:
        """Rebalance to PRE-COMPUTED per-symbol target weights (e.g. the multi-strat book's
        `multistrat.target_weights`), the caller supplying each symbol's current price. Symbols are BROKER
        symbols — e.g. equity "SPY" — so the caller owns any data↔broker symbol mapping.
        Reuses the SAME safety gates + fractional sizing as `rebalance(book)`.

        `contract_specs` (symbol -> {exchange, currency, sec_id_type, sec_id}) enriches the placed Order so a
        non-SMART/USD line (the UCITS route) resolves; symbols absent from it keep the SMART/USD defaults (the
        US path passes None and is byte-unchanged)."""
        sized = {sym: (float(w), float(prices[sym])) for sym, w in weights.items()}
        return self._plan(name, sized, max_gross, execute=execute, contract_specs=contract_specs or {},
                          sizing_capital=sizing_capital)

    def _plan(self, name: str, targets: dict[str, tuple[float, float]], max_gross: float, *,
              execute: bool, contract_specs: dict[str, dict[str, str]] | None = None,
              sizing_capital: float | None = None) -> RebalancePlan:
        """Shared engine over `{symbol: (weight, price)}`: safety gates → entry-floor/hysteresis sizing
        (WHOLE shares, or FRACTIONAL when the broker advertises it) → placement. The gates are hard-won from
        live paper trading."""
        ex = self._s.execution
        notes: list[str] = []
        acct = self._broker.account_state()

        # data-consistency sanity (the IBKR-paper glitch lesson): cross-check NLV vs cash+positions,
        # size on the CONSERVATIVE value, hard-block if they disagree badly.
        nlv = acct.nlv
        data_gap = 0.0
        if acct.gross_position_value is not None:
            est = acct.cash + acct.gross_position_value
            data_gap = abs(acct.nlv - est) / max(acct.nlv, est, 1.0)
            if acct.nlv > 0 and est > 0:
                nlv = min(acct.nlv, est)
            if data_gap > 0.02:
                notes.append(f"account data inconsistent (gap {100*data_gap:.1f}%) — using conservative NLV")

        plan = RebalancePlan(book=name, nlv=nlv,
                             target_weights={sym: round(w, 4) for sym, (w, _) in targets.items()})

        # --- gates (refuse to act on unsafe state) ---
        if not acct.is_paper and not ex.allow_live:
            plan.blocked = True
            plan.notes = [*notes, "LIVE account but allow_live not set — refusing real money"]
            return plan
        if data_gap > ex.data_tol:
            plan.blocked = True
            plan.notes = [*notes, f"account data gap {100*data_gap:.0f}% > {100*ex.data_tol:.0f}% — broker data unreliable, refusing"]
            return plan
        gross = sum(abs(w) for w, _ in targets.values())
        if gross > max_gross + 1e-9:
            plan.blocked = True
            plan.notes = [*notes, f"gross exposure {gross:.2f} > max {max_gross}"]
            return plan
        if nlv <= 0:
            plan.blocked = True
            plan.notes = [*notes, "NLV <= 0"]
            return plan

        # The entry-floor / hysteresis thresholds must be relative to the CAPITAL ACTUALLY BEING DEPLOYED, not
        # the raw account NLV. When the book is sized to a paper envelope (weights pre-scaled by envelope/nlv,
        # so `nlv*weight` == `envelope*within_weight`), the order values live on the envelope basis — but a
        # threshold of `entry_floor*nlv` would be on the (10x larger) NLV basis, silently dropping every sleeve
        # below ~entry_floor*(nlv/envelope) of the envelope (e.g. a 3% gold sleeve at a $100k envelope on a
        # $1M paper account). Use the envelope (capped by the conservative NLV) so the floor means what it says.
        floor_base = min(float(sizing_capital), nlv) if (sizing_capital and sizing_capital > 0) else nlv
        frac = getattr(self._broker, "supports_fractional", False)
        orders: list[PlannedOrder] = []
        for sym, (weight, px) in targets.items():
            target_shares = nlv * weight / px
            current = acct.positions.get(sym, 0.0)
            delta = target_shares - current
            # A full EXIT (target weight 0 on a held line — e.g. closing a superseded UCITS line) ALWAYS
            # trades: hysteresis exists to damp churn, not to strand a position we're deliberately closing.
            is_exit = weight == 0.0 and current != 0.0
            threshold = (ex.entry_floor if current == 0 else ex.hysteresis) * floor_base
            if not is_exit and abs(delta * px) <= threshold:
                continue
            if frac:
                qty: float = round(abs(delta), 6)                          # fractional shares
                if qty * px < self._s.execution.min_order_notional:       # skip sub-min-notional dust
                    continue
            else:
                qty = int(round(abs(delta)))                               # whole shares (IBKR)
                if qty == 0:
                    continue
            orders.append(PlannedOrder(symbol=sym, side="BUY" if delta > 0 else "SELL",
                                       quantity=qty, est_value=round(qty * px)))
        plan.orders = orders

        if not orders:
            notes.append("in band — nothing to do")
        elif execute:
            specs = contract_specs or {}
            placed = 0
            unfilled: list[str] = []
            for o in orders:
                try:
                    spec = specs.get(o.symbol, {})
                    status = self._broker.place_order(Order(
                        symbol=o.symbol, side=o.side, quantity=o.quantity,
                        exchange=spec.get("exchange", "SMART"), currency=spec.get("currency", "USD"),
                        sec_id_type=spec.get("sec_id_type"), sec_id=spec.get("sec_id")))
                    notes.append(f"{o.side} {o.quantity:g} {o.symbol}: {status}")
                    placed += 1
                    if not _is_fill(status):
                        unfilled.append(f"{o.symbol}({status})")
                except Exception as e:  # one bad order must not abort the rest
                    notes.append(f"order failed {o.symbol}: {str(e)[:80]}")
                    unfilled.append(f"{o.symbol}(error)")
            # Real money errs false-WAIT: the plan is EXECUTED only when EVERY attempted leg actually FILLED.
            # A Cancelled/Submitted leg (Error 478/10349, or a broker that raised) is a DEGRADED run — reporting
            # EXECUTED there would let the executed forward track + reconciliation gate book a rebalance that
            # did not happen (live 2026-07-20: CBU0 Cancelled + DBMF stuck Submitted, yet the run said
            # EXECUTED). `placed > 0` is NOT sufficient; all placed legs must be fills.
            plan.executed = placed > 0 and not unfilled
            if unfilled:
                notes.append(f"DEGRADED — {len(unfilled)} leg(s) did not fill: {', '.join(unfilled)} "
                             f"(NOT reported EXECUTED)")
        plan.notes = notes
        pop_trades = getattr(self._broker, "pop_trades", None)   # only IbkrBroker exposes this (D2.1)
        if callable(pop_trades):
            plan.trades = pop_trades()
        return plan
