"""
APEX V16 — Trade closer.

SINGLE POINT for closing a trade. Replaces V15's _close_trade (line 2148).

Responsibilities:
  - Compute final P&L from exit price (using pnl_calculator)
  - If live: call broker close_position + cancel orphan SL/TP orders
  - Build TradeCloseResult with all fields the orchestrator needs

NOT responsibilities:
  - Mutating state.active_trades (orchestrator does it after success)
  - Updating counters (orchestrator does it after success)
  - Saving state (orchestrator calls state_store.save() after)
  - Logging the trade_closed event (orchestrator logs)

Why split? V15-BUG-9: V15's _close_trade did everything inline
(close + del active + save_state) but in some error paths save_state
was skipped, leaving state file out of sync with broker. V16 separates
the broker-facing work from state mutation. Orchestrator owns the
sequencing and atomicity.

Failure modes:
  - Broker close fails -> result.success=False, error populated.
    Orchestrator decides: retry, mark as orphan, halt, etc.
  - In paper mode, never fails (just computes P&L).
"""

from __future__ import annotations

import asyncio
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Optional

from broker.broker_base import BrokerBase
from core.contracts import TradeEntry
from trading.pnl_calculator import compute_pnl, PnLResult


# ============================================================
# RESULT TYPE
# ============================================================

@dataclass(frozen=True)
class TradeCloseResult:
    """
    Result of close_trade(). On success, the orchestrator uses this
    to update state, counters, and log the event.
    """
    success: bool
    symbol: str
    brain_name: str
    direction: str
    contracts: int
    reason: str

    # Financial summary
    entry_price: float = 0.0
    exit_price: float = 0.0
    profit_ticks: int = 0
    net_profit_usd: float = 0.0
    is_win: bool = False

    # Timing
    closed_at: Optional[datetime] = None
    duration_minutes: float = 0.0

    # Live-only
    broker_orders_cancelled: int = 0   # orphan SL/TP orders cleaned up
    # V17 bug #3: which stage of the orphan-cancel cascade produced the
    # hits — "id_first" | "searchopen" | "retry" | "none".
    orphan_cancel_method: str = ""

    # Failure
    error: str = ""
    error_kind: str = ""    # "broker_failure" | "config_error"

    # V18 12-mag — IDs del bracket ricostruito sul residuo post-partial.
    # Valorizzati solo da partial_close (live mode): caller aggiorna
    # active.entry.stop_order_id / target_order_id quando presenti.
    new_stop_order_id: Optional[str] = None
    new_target_order_id: Optional[str] = None


# ============================================================
# CLOSER
# ============================================================

class TradeCloser:
    """
    Closes a trade and returns a structured result.
    Used by orchestrator's track_trades loop and by direct exit signals.
    """

    def __init__(self, broker: BrokerBase, is_paper: bool, logger=None) -> None:
        """
        Args:
            broker: BrokerBase (used only in live mode)
            is_paper: True = simulation, no broker call
            logger: LoggerBundle (optional)
        """
        self.broker = broker
        self.is_paper = is_paper
        self.logger = logger

    # ============================================================
    # PUBLIC API
    # ============================================================

    async def close_trade(
        self,
        entry: TradeEntry,
        exit_price: float,
        reason: str,
        tick_size: float,
        tick_value: float,
    ) -> TradeCloseResult:
        """
        Close a trade and return a TradeCloseResult.

        Args:
            entry: the immutable TradeEntry from when the trade opened
            exit_price: actual exit price (broker fill, or tech price for paper)
            reason: human-readable reason ("SL_HIT", "TP_HIT", "BRAIN_EXIT",
                    "TIME_STOP", "BROKER_NATURAL_TP_HIT", "FORCE_FLAT")
            tick_size: from config_futures (asset-specific)
            tick_value: from config_futures (asset-specific)

        Returns:
            TradeCloseResult.
        """
        # --- Compute final P&L (pure, no I/O) ---
        # Use SL_PRICE for sl gate just to satisfy compute_pnl signature;
        # for closing what matters is current_price as exit_price.
        # We don't care about hit_sl/hit_tp flags here — caller already
        # decided to close.
        try:
            pnl: PnLResult = compute_pnl(
                entry_price=entry.entry_price,
                sl_price=entry.sl_price,
                tp_price=entry.tp_price,
                current_price=exit_price,
                direction=entry.direction,
                contracts=entry.contracts,
                tick_size=tick_size,
                tick_value=tick_value,
            )
        except ValueError as e:
            return TradeCloseResult(
                success=False,
                symbol=entry.symbol,
                brain_name=entry.brain_name,
                direction=entry.direction,
                contracts=entry.contracts,
                reason=reason,
                error=f"P&L computation failed: {e}",
                error_kind="config_error",
            )

        # --- Compute duration ---
        now = datetime.now(timezone.utc)
        duration_min = (now - entry.opened_at).total_seconds() / 60.0

        # --- Branch: paper vs live ---
        if self.is_paper or entry.is_paper:
            # Paper: no broker call needed
            return TradeCloseResult(
                success=True,
                symbol=entry.symbol,
                brain_name=entry.brain_name,
                direction=entry.direction,
                contracts=entry.contracts,
                reason=reason,
                entry_price=entry.entry_price,
                exit_price=exit_price,
                profit_ticks=pnl.profit_ticks,
                net_profit_usd=pnl.net_profit_usd_int,  # tick-aligned for accounting
                is_win=pnl.net_profit_usd_int > 0,
                closed_at=now,
                duration_minutes=round(duration_min, 1),
            )

        # --- Live: close broker position + cleanup orphan SL/TP ---
        return await self._close_live(entry, exit_price, reason, pnl, now, duration_min)

    # ============================================================
    # LIVE PATH
    # ============================================================

    async def _close_live(
        self,
        entry: TradeEntry,
        exit_price: float,
        reason: str,
        pnl: PnLResult,
        now: datetime,
        duration_min: float,
    ) -> TradeCloseResult:
        """
        Live close: call broker.close_position, then cancel any orphan
        SL/TP orders that may still be active at broker.

        Note on "natural close" reasons (SL/TP hit broker-side): in those
        cases the position is already gone at broker; close_position is
        a no-op but cancel_all_for_symbol still cleans up orphan orders.

        V17 bug #3: orphan cancel uses a 3-stage cascade (same as
        orchestrator._check_external_close):
          Stage 1 (id_first):   direct cancel_order on entry.stop_order_id /
                                target_order_id (bypasses searchOpen filter)
          Stage 2 (searchopen): cancel_all_for_symbol (catches non-bracket
                                orphans like modify_stop residuals)
          Stage 3 (retry):      if total still 0, sleep 2s and retry searchOpen
                                once (handles broker order-list propagation race)
        """
        cancelled_count = 0
        orphan_cancel_method = "none"
        broker_error = None

        # --- Step 1: close position (best effort; tolerate "no position") ---
        try:
            close_result = await self.broker.close_position(entry.symbol)
            # close_result may be None or a structured object depending on
            # broker implementation; we don't strictly require a result here
        except Exception as e:
            # Tolerate: position might already be closed broker-side
            # (natural SL/TP fill). We still try to cleanup orphan orders
            # if the position is verifiably gone — see Step 1b.
            broker_error = f"close_position failed: {e}"
            self._log_warning(f"close_position {entry.symbol}: {e}")

        # --- Step 1b: verify position actually closed (V17 naked-position fix) ---
        # Two ways the bot can leak a naked position if we proceed straight to
        # cancelling SL/TP:
        #   (a) close_position raised but the broker still holds the position
        #       (network/timeout, broker-side error) — fix 1f8874d.
        #   (b) close_position returned success but the broker did NOT close
        #       the position. Observed recurring incident on TopstepX
        #       (6E/6B/MCL): broker acks the close request, bot cancels
        #       SL/TP, position stays open naked.
        # In both cases, cancelling SL/TP would strip the position's only
        # protection. ALWAYS probe positions_get after close_position; if
        # the position is still there OR the probe fails, bail out without
        # touching SL/TP. Orchestrator keeps state.active_trade and retries
        # next manage tick. SL/TP stay active as protection during the gap.
        position_still_open = False
        try:
            positions = await self.broker.positions_get(entry.symbol)
            position_still_open = bool(positions)
        except Exception as e:
            # Probe failed. Conservative: assume still open and bail.
            # A retry-able stuck close is recoverable; a naked position
            # is not.
            self._log_warning(
                f"positions_get probe failed after close attempt "
                f"{entry.symbol}: {e}; treating as still open",
            )
            position_still_open = True

        if position_still_open:
            if broker_error is not None:
                error_kind = "broker_failure_position_open"
                error_msg = broker_error
            else:
                error_kind = "close_success_but_position_open"
                error_msg = (
                    "close_position returned success but position still open"
                )
            return TradeCloseResult(
                success=False,
                symbol=entry.symbol,
                brain_name=entry.brain_name,
                direction=entry.direction,
                contracts=entry.contracts,
                reason=reason,
                entry_price=entry.entry_price,
                exit_price=exit_price,
                profit_ticks=pnl.profit_ticks,
                net_profit_usd=pnl.net_profit_usd_int,
                is_win=False,
                closed_at=now,
                duration_minutes=round(duration_min, 1),
                error=error_msg,
                error_kind=error_kind,
            )

        # --- Step 2: cleanup orphan SL/TP orders (3-stage cascade) ---
        # Stage 1: ID-first
        cancelled_id_first = 0
        for tag, oid in (
            ("stop", entry.stop_order_id),
            ("target", entry.target_order_id),
        ):
            if not oid:
                continue
            try:
                res = await self.broker.cancel_order(entry.symbol, oid)
            except Exception as e:
                self._log_warning(
                    f"cancel_order({entry.symbol}, {tag}={oid}): {e}",
                )
                continue
            if getattr(res, "success", False):
                cancelled_id_first += 1
        cancelled_count += cancelled_id_first
        if cancelled_id_first > 0:
            orphan_cancel_method = "id_first"

        # Stage 2: searchOpen safety net
        cancelled_searchopen = 0
        try:
            cancelled_searchopen = await self.broker.cancel_all_for_symbol(
                entry.symbol,
            )
        except Exception as e:
            # Cancel cleanup failure is logged but doesn't fail the close.
            # A stale order at broker is bad but the trade close itself
            # is not invalidated by it.
            self._log_warning(f"cancel_all_for_symbol {entry.symbol}: {e}")
        cancelled_count += cancelled_searchopen
        if cancelled_searchopen > 0 and orphan_cancel_method == "none":
            orphan_cancel_method = "searchopen"

        # Stage 3: retry searchOpen once if both stages returned 0
        if cancelled_count == 0:
            await asyncio.sleep(2.0)
            cancelled_retry = 0
            try:
                cancelled_retry = await self.broker.cancel_all_for_symbol(
                    entry.symbol,
                )
            except Exception as e:
                self._log_warning(
                    f"cancel_all_for_symbol {entry.symbol} retry: {e}",
                )
            cancelled_count += cancelled_retry
            if cancelled_retry > 0:
                orphan_cancel_method = "retry"

        # --- Build result ---
        # Even if close_position raised, we treat the close as "successful"
        # if broker no longer has the position (which is the goal). The
        # orchestrator can re-verify position state after if paranoid.
        success = broker_error is None

        return TradeCloseResult(
            success=success,
            symbol=entry.symbol,
            brain_name=entry.brain_name,
            direction=entry.direction,
            contracts=entry.contracts,
            reason=reason,
            entry_price=entry.entry_price,
            exit_price=exit_price,
            profit_ticks=pnl.profit_ticks,
            net_profit_usd=pnl.net_profit_usd_int,
            is_win=pnl.net_profit_usd_int > 0,
            closed_at=now,
            duration_minutes=round(duration_min, 1),
            broker_orders_cancelled=cancelled_count,
            orphan_cancel_method=orphan_cancel_method,
            error=broker_error or "",
            error_kind="broker_failure" if broker_error else "",
        )

    # ============================================================
    # PARTIAL CLOSE
    # ============================================================

    async def partial_close(
        self,
        entry: TradeEntry,
        exit_price: float,
        contracts_to_close: int,
        reason: str,
        tick_size: float,
        tick_value: float,
        *,
        new_sl_price: Optional[float] = None,
        new_tp_price: Optional[float] = None,
    ) -> TradeCloseResult:
        """
        Close a fraction of an open position. Used for PARTIAL_50 brain action.

        V18 12-mag — refactor: usa il nuovo `partial_close_via_opposite_order`
        invece di `close_position(size=N)`. Su alcuni asset ProjectX (6A
        confermato) `/Position/partialCloseContract` ritorna 400, mentre
        il flusso "market opposito + cancel SL/TP + nuovo bracket sul
        residuo" funziona universalmente.

        Il broker piazza:
          1. market order opposto × contracts_to_close (chiude metà)
          2. cancel SL+TP originali
          3. nuovo STOP @ `new_sl_price` × residual
          4. nuovo LIMIT @ `new_tp_price` × residual

        Caller (orchestrator) passa:
          - new_sl_price: be_price quando set_be_after_partial, altrimenti
                          il prezzo SL corrente (entry.sl_price o
                          runtime.current_sl_price).
          - new_tp_price: entry.tp_price (residuo mantiene stesso TP).

        Args:
            entry:               immutable TradeEntry of the open trade
            exit_price:          fill price stimato (paper: tech price;
                                 live: best-available market)
            contracts_to_close:  must be 0 < N < entry.contracts
            reason:              "PARTIAL_50_BRAIN" | "RSI50_PARTIAL" | ...
            tick_size, tick_value: ASSETS_MAP for this symbol
            new_sl_price:        SL price del nuovo stop sul residuo (live only)
            new_tp_price:        TP price del nuovo limit sul residuo (live only)

        Returns:
            TradeCloseResult con profit_ticks/net_profit_usd computati su
            `contracts_to_close`. In live, `new_stop_order_id` /
            `new_target_order_id` valorizzati con gli ID del nuovo bracket.

        Failure modes:
          - contracts_to_close <=0 or >= entry.contracts -> ValueError
          - Live broker rejection -> success=False, error populated
        """
        if contracts_to_close <= 0 or contracts_to_close >= entry.contracts:
            raise ValueError(
                f"contracts_to_close must be in (0, {entry.contracts}), "
                f"got {contracts_to_close}"
            )

        try:
            pnl: PnLResult = compute_pnl(
                entry_price=entry.entry_price,
                sl_price=entry.sl_price,
                tp_price=entry.tp_price,
                current_price=exit_price,
                direction=entry.direction,
                contracts=contracts_to_close,
                tick_size=tick_size,
                tick_value=tick_value,
            )
        except ValueError as e:
            return TradeCloseResult(
                success=False,
                symbol=entry.symbol,
                brain_name=entry.brain_name,
                direction=entry.direction,
                contracts=contracts_to_close,
                reason=reason,
                error=f"P&L computation failed: {e}",
                error_kind="config_error",
            )

        now = datetime.now(timezone.utc)
        duration_min = (now - entry.opened_at).total_seconds() / 60.0

        # Paper: no broker call, just P&L on the half
        if self.is_paper or entry.is_paper:
            return TradeCloseResult(
                success=True,
                symbol=entry.symbol,
                brain_name=entry.brain_name,
                direction=entry.direction,
                contracts=contracts_to_close,
                reason=reason,
                entry_price=entry.entry_price,
                exit_price=exit_price,
                profit_ticks=pnl.profit_ticks,
                net_profit_usd=pnl.net_profit_usd_int,
                is_win=pnl.net_profit_usd_int > 0,
                closed_at=now,
                duration_minutes=round(duration_min, 1),
                broker_orders_cancelled=0,
            )

        # Live: broker partial close via opposite market + bracket rebuild.
        # V18 12-mag — sostituisce close_position(size=N) che fallisce 400
        # su alcuni asset ProjectX (6A confermato).
        residual_contracts = entry.contracts - contracts_to_close
        # Fallback se il caller non passa i prezzi nuovi: SL@entry.sl_price,
        # TP@entry.tp_price (residuo mantiene il bracket originale).
        sl_for_residual = new_sl_price if new_sl_price is not None else entry.sl_price
        tp_for_residual = new_tp_price if new_tp_price is not None else entry.tp_price
        try:
            close_result = await self.broker.partial_close_via_opposite_order(
                symbol=entry.symbol,
                direction=entry.direction,
                contracts_to_close=contracts_to_close,
                residual_contracts=residual_contracts,
                new_sl_price=float(sl_for_residual),
                new_tp_price=float(tp_for_residual),
                old_stop_order_id=entry.stop_order_id,
                old_target_order_id=entry.target_order_id,
            )
        except Exception as e:
            return TradeCloseResult(
                success=False,
                symbol=entry.symbol,
                brain_name=entry.brain_name,
                direction=entry.direction,
                contracts=contracts_to_close,
                reason=reason,
                error=f"partial_close_via_opposite_order failed: {e}",
                error_kind="broker_failure",
            )

        # Best-effort interpretation: if broker returns a structured result
        # with success=False, propagate. Otherwise treat as success.
        ok = True
        err = ""
        new_stop_id: Optional[str] = None
        new_target_id: Optional[str] = None
        if close_result is not None:
            ok_attr = getattr(close_result, "success", None)
            if ok_attr is False:
                ok = False
                err = getattr(close_result, "error", "broker rejected partial close")
            else:
                new_stop_id = getattr(close_result, "stop_id", None)
                new_target_id = getattr(close_result, "target_id", None)

        return TradeCloseResult(
            success=ok,
            symbol=entry.symbol,
            brain_name=entry.brain_name,
            direction=entry.direction,
            contracts=contracts_to_close,
            reason=reason,
            entry_price=entry.entry_price,
            exit_price=exit_price,
            profit_ticks=pnl.profit_ticks,
            net_profit_usd=pnl.net_profit_usd_int if ok else 0.0,
            is_win=(pnl.net_profit_usd_int > 0) if ok else False,
            closed_at=now,
            duration_minutes=round(duration_min, 1),
            broker_orders_cancelled=0,
            error=err,
            error_kind="broker_failure" if not ok else "",
            new_stop_order_id=new_stop_id,
            new_target_order_id=new_target_id,
        )

    # ============================================================
    # LOGGING HELPERS
    # ============================================================

    def _log_warning(self, msg: str) -> None:
        if self.logger is not None:
            self.logger.system.warning(f"[trade_closer] {msg}")
        else:
            print(f"[trade_closer WARN] {msg}")
