"""Fill execution orchestration.

This module provides FillExecutor which handles order fill execution,
extracting the logic from Broker._execute_fill() into a focused class
with helper methods for position creation, closing, flipping, and scaling.
"""

from __future__ import annotations

from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING

from ..config import InitialHwmSource
from ..types import (
    ExitReason,
    Fill,
    Order,
    OrderSide,
    OrderStatus,
    Position,
    Trade,
)

if TYPE_CHECKING:
    from ..broker import Broker


def _get_exit_reason(order: Order) -> str:
    """Get exit reason from order.

    Args:
        order: Order with exit reason metadata

    Returns:
        ExitReason enum value as string
    """
    if order._exit_reason is not None:
        return order._exit_reason.value
    return ExitReason.SIGNAL.value


@dataclass
class FillContext:
    """Context for a single fill execution.

    Encapsulates all the data needed to execute a fill without
    passing many individual parameters between methods.
    """

    order: Order
    current_time: datetime  # Validated timestamp for fill
    fill_quantity: float
    fill_price: float
    commission: float
    slippage: float
    signed_qty: float  # fill_quantity with sign (positive=buy, negative=sell)
    is_partial: bool
    price_source: str
    quote_context: dict[str, float | None]


class FillExecutor:
    """Orchestrates order fill execution.

    Extracts fill execution logic from Broker into a focused class with
    helper methods for each type of position change:
    - create_position: New position from flat
    - close_position: Close existing position to flat
    - flip_position: Reverse position (long→short or short→long)
    - scale_position: Add to or reduce existing position

    Example:
        >>> executor = FillExecutor(broker)
        >>> fully_filled = executor.execute(order, base_price=100.0)
    """

    def __init__(self, broker: Broker):
        """Initialize with broker instance.

        Args:
            broker: The Broker instance whose state we'll modify
        """
        self.broker = broker
        self._qty_zero_epsilon = 1e-12

    def execute(self, order: Order, base_price: float) -> bool:
        """Execute a fill and update positions.

        This is the main entry point, replacing Broker._execute_fill().

        Args:
            order: Order to fill
            base_price: Base fill price before adjustments

        Returns:
            True if order is fully filled, False if partially filled
        """
        broker = self.broker
        current_time = broker._current_time
        assert current_time is not None, "Cannot execute fill without current time"

        available_size = broker.get_available_size(order.asset, order.side)

        # Get effective quantity (considering partial fills from previous bars)
        effective_quantity = broker._fill_engine.get_effective_quantity(order)
        fill_quantity = effective_quantity

        # Apply execution limits (volume participation)
        if broker.execution_limits is not None:
            if order.order_id in broker._filled_this_bar:
                return False

            exec_result = broker.execution_limits.calculate(
                effective_quantity,
                available_size,
                base_price,
            )
            fill_quantity = exec_result.fillable_quantity

            if fill_quantity <= 0:
                return False

            broker._filled_this_bar.add(order.order_id)

            if exec_result.remaining_quantity > 0:
                broker._partial_orders[order.order_id] = exec_result.remaining_quantity
            else:
                broker._partial_orders.pop(order.order_id, None)

        # Apply market impact
        if broker.market_impact_model is not None:
            is_buy = order.side == OrderSide.BUY
            impact = broker.market_impact_model.calculate(
                fill_quantity,
                base_price,
                available_size,
                is_buy,
            )
            base_price = base_price + impact

        # Calculate slippage
        slippage = broker.slippage_model.calculate(
            order.asset,
            fill_quantity,
            base_price,
            available_size,
        )
        fill_price = base_price + slippage if order.side == OrderSide.BUY else base_price - slippage

        # Calculate commission
        commission = broker.commission_model.calculate(order.asset, fill_quantity, fill_price)
        quote_context = broker.get_quote_context(order.asset, order.side)

        # Create fill record
        fill = Fill(
            order_id=order.order_id,
            rebalance_id=order.rebalance_id,
            asset=order.asset,
            side=order.side,
            quantity=fill_quantity,
            price=fill_price,
            timestamp=current_time,
            commission=commission,
            slippage=slippage,
            order_type=order.order_type.value,
            limit_price=order.limit_price,
            stop_price=order.stop_price,
            price_source=broker.execution_price.value,
            reference_price=quote_context["reference_price"],
            quote_mid_price=quote_context["quote_mid_price"],
            bid_price=quote_context["bid_price"],
            ask_price=quote_context["ask_price"],
            spread=quote_context["spread"],
            bid_size=quote_context["bid_size"],
            ask_size=quote_context["ask_size"],
            available_size=quote_context["available_size"],
        )
        broker.fills.append(fill)

        # Determine if partial fill
        is_partial = order.order_id in broker._partial_orders
        if is_partial:
            order.filled_quantity = (order.filled_quantity or 0) + fill_quantity
        else:
            order.status = OrderStatus.FILLED
            order.filled_at = current_time
            order.filled_price = fill_price
            order.filled_quantity = fill_quantity

        # Build fill context
        signed_qty = fill_quantity if order.side == OrderSide.BUY else -fill_quantity
        ctx = FillContext(
            order=order,
            current_time=current_time,
            fill_quantity=fill_quantity,
            fill_price=fill_price,
            commission=commission,
            slippage=slippage,
            signed_qty=signed_qty,
            is_partial=is_partial,
            price_source=broker.execution_price.value,
            quote_context=quote_context,
        )

        # Update position and get actual commission (may change for flips)
        actual_commission = self._update_position(ctx)

        # Update cash (include multiplier for futures/derivatives)
        multiplier = broker.get_multiplier(order.asset)
        cash_change = -signed_qty * fill_price * multiplier - actual_commission
        broker.cash += cash_change

        # Sync position to AccountState using execution price for this fill.
        # In next-bar/open execution this avoids close-price mark-to-market
        # leaking into same-cycle buying-power checks.
        self._sync_account_state(order.asset, current_price=ctx.fill_price)

        # Update account cash
        broker.account.cash = broker.cash

        # Settlement delay: hold sale proceeds until settlement completes
        if broker.settlement_delay > 0 and cash_change > 0:
            broker.account.add_settlement_hold(
                broker._bar_index, broker.settlement_delay, cash_change
            )

        # Cancel sibling bracket orders on full fill
        if order.parent_id and not is_partial:
            for o in broker.pending_orders[:]:
                if o.parent_id == order.parent_id and o.order_id != order.order_id:
                    o.status = OrderStatus.CANCELLED
                    broker.pending_orders.remove(o)

        return not is_partial

    def _update_position(self, ctx: FillContext) -> float:
        """Update position based on fill.

        Args:
            ctx: Fill context with all execution details

        Returns:
            Actual commission charged (may differ from ctx.commission for flips)
        """
        broker = self.broker
        pos = broker.positions.get(ctx.order.asset)

        if pos is None:
            if ctx.signed_qty != 0:
                self._create_position(ctx)
            return ctx.commission
        else:
            old_qty = pos.quantity
            new_qty = old_qty + ctx.signed_qty
            if abs(new_qty) < self._qty_zero_epsilon:
                new_qty = 0.0

            if new_qty == 0:
                self._close_position(ctx, pos, old_qty)
                return ctx.commission
            elif (old_qty > 0) != (new_qty > 0):
                return self._flip_position(ctx, pos, old_qty, new_qty)
            else:
                self._scale_position(ctx, pos, old_qty, new_qty)
                return ctx.commission

    def _get_initial_hwm(self, asset: str, fill_price: float) -> float:
        """Get initial high water mark based on configuration.

        This is the single source of truth for HWM initialization,
        eliminating the duplication that existed in _execute_fill().

        Args:
            asset: Asset symbol
            fill_price: Fill price (default fallback)

        Returns:
            Initial HWM value based on configuration
        """
        broker = self.broker
        if broker.initial_hwm_source == InitialHwmSource.BAR_HIGH:
            return broker._current_highs.get(asset, fill_price)
        elif broker.initial_hwm_source == InitialHwmSource.BAR_CLOSE:
            return broker._current_closes.get(asset, broker._current_prices.get(asset, fill_price))
        else:
            return fill_price

    def _get_initial_lwm(self, asset: str, fill_price: float) -> float:
        """Get initial low water mark based on configuration.

        For VBT Pro compatibility with OHLC data, LWM should be initialized
        from the entry bar's LOW price, not the high. This is critical for
        short positions where trailing stops use LWM.

        Args:
            asset: Asset symbol
            fill_price: Fill price (default fallback)

        Returns:
            Initial LWM value based on configuration
        """
        broker = self.broker
        # When using BAR_HIGH for HWM, use BAR_LOW for LWM
        if broker.initial_hwm_source == InitialHwmSource.BAR_HIGH:
            return broker._current_lows.get(asset, fill_price)
        elif broker.initial_hwm_source == InitialHwmSource.BAR_CLOSE:
            return broker._current_closes.get(asset, broker._current_prices.get(asset, fill_price))
        else:
            return fill_price

    def _build_position_context(self, order: Order) -> dict:
        """Build position context with signal_price.

        This is the single source of truth for context building,
        eliminating the duplication that existed in _execute_fill().

        Args:
            order: Order with optional _signal_price

        Returns:
            Context dict for Position
        """
        broker = self.broker
        signal_price = getattr(order, "_signal_price", None)
        context = {
            "stop_fill_mode": broker.stop_fill_mode,
            "stop_level_basis": broker.stop_level_basis,
            "trail_hwm_source": broker.trail_hwm_source,
            "trail_stop_timing": broker.trail_stop_timing,
            "entry_quote_context": broker.get_quote_context(order.asset, order.side),
        }
        if signal_price is not None:
            context["signal_price"] = signal_price
        return context

    def _create_position(self, ctx: FillContext) -> None:
        """Create a new position from flat.

        Args:
            ctx: Fill context
        """
        broker = self.broker
        order = ctx.order

        initial_hwm = self._get_initial_hwm(order.asset, ctx.fill_price)
        initial_lwm = self._get_initial_lwm(order.asset, ctx.fill_price)
        context = self._build_position_context(order)

        pos = Position(
            asset=order.asset,
            quantity=ctx.signed_qty,
            entry_price=ctx.fill_price,
            entry_time=ctx.current_time,
            context=context,
            multiplier=broker.get_multiplier(order.asset),
            entry_commission=ctx.commission,
            entry_slippage=ctx.slippage,
            high_water_mark=initial_hwm,
            low_water_mark=initial_lwm,
        )
        broker.positions[order.asset] = pos
        broker._positions_created_this_bar.add(order.asset)

    def _close_position(self, ctx: FillContext, pos: Position, old_qty: float) -> None:
        """Close an existing position to flat.

        Args:
            ctx: Fill context
            pos: Position being closed
            old_qty: Original position quantity
        """
        broker = self.broker
        order = ctx.order

        # PnL includes both entry and exit commission, and multiplier for futures
        total_commission = pos.entry_commission + ctx.commission
        pnl = (ctx.fill_price - pos.entry_price) * old_qty * pos.multiplier - total_commission
        raw_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0.0
        pnl_pct = raw_pct if old_qty > 0 else -raw_pct
        entry_quote = pos.context.get("entry_quote_context", {})
        exit_quote = ctx.quote_context

        trade = Trade(
            symbol=order.asset,  # Order.asset -> Trade.symbol
            entry_time=pos.entry_time,
            exit_time=ctx.current_time,
            entry_price=pos.entry_price,
            exit_price=ctx.fill_price,
            quantity=old_qty,
            pnl=pnl,
            pnl_percent=pnl_pct,
            bars_held=pos.bars_held,
            fees=total_commission,
            exit_slippage=ctx.slippage,
            exit_reason=_get_exit_reason(order),
            mfe=pos.max_favorable_excursion,
            mae=pos.max_adverse_excursion,
            entry_slippage=pos.entry_slippage,
            multiplier=pos.multiplier,
            entry_quote_mid_price=entry_quote.get("quote_mid_price"),
            entry_bid_price=entry_quote.get("bid_price"),
            entry_ask_price=entry_quote.get("ask_price"),
            entry_spread=entry_quote.get("spread"),
            entry_available_size=entry_quote.get("available_size"),
            exit_quote_mid_price=exit_quote.get("quote_mid_price"),
            exit_bid_price=exit_quote.get("bid_price"),
            exit_ask_price=exit_quote.get("ask_price"),
            exit_spread=exit_quote.get("spread"),
            exit_available_size=exit_quote.get("available_size"),
        )
        broker.trades.append(trade)
        del broker.positions[order.asset]

        # Record P&L event for trading stats
        broker._record_pnl_event(order.asset, pnl)

    def _flip_position(
        self, ctx: FillContext, pos: Position, old_qty: float, new_qty: float
    ) -> float:
        """Handle position flip (long→short or short→long).

        Args:
            ctx: Fill context
            pos: Position being flipped
            old_qty: Original position quantity
            new_qty: New position quantity (opposite sign)

        Returns:
            Total commission charged (close + open portions)
        """
        broker = self.broker
        order = ctx.order

        close_qty = abs(old_qty)
        open_qty = abs(new_qty)

        # Calculate separate commissions for close and open portions
        close_commission = broker.commission_model.calculate(order.asset, close_qty, ctx.fill_price)
        open_commission = broker.commission_model.calculate(order.asset, open_qty, ctx.fill_price)
        total_commission = close_commission + open_commission

        # Close the old position (include multiplier for futures)
        total_close_commission = pos.entry_commission + close_commission
        pnl = (ctx.fill_price - pos.entry_price) * old_qty * pos.multiplier - total_close_commission
        raw_pct = (ctx.fill_price - pos.entry_price) / pos.entry_price if pos.entry_price else 0.0
        pnl_pct = raw_pct if old_qty > 0 else -raw_pct
        entry_quote = pos.context.get("entry_quote_context", {})
        exit_quote = ctx.quote_context

        trade = Trade(
            symbol=order.asset,  # Order.asset -> Trade.symbol
            entry_time=pos.entry_time,
            exit_time=ctx.current_time,
            entry_price=pos.entry_price,
            exit_price=ctx.fill_price,
            quantity=old_qty,
            pnl=pnl,
            pnl_percent=pnl_pct,
            bars_held=pos.bars_held,
            fees=total_close_commission,
            exit_slippage=ctx.slippage * (close_qty / ctx.fill_quantity),
            exit_reason=_get_exit_reason(order),
            mfe=pos.max_favorable_excursion,
            mae=pos.max_adverse_excursion,
            entry_slippage=pos.entry_slippage,
            multiplier=pos.multiplier,
            entry_quote_mid_price=entry_quote.get("quote_mid_price"),
            entry_bid_price=entry_quote.get("bid_price"),
            entry_ask_price=entry_quote.get("ask_price"),
            entry_spread=entry_quote.get("spread"),
            entry_available_size=entry_quote.get("available_size"),
            exit_quote_mid_price=exit_quote.get("quote_mid_price"),
            exit_bid_price=exit_quote.get("bid_price"),
            exit_ask_price=exit_quote.get("ask_price"),
            exit_spread=exit_quote.get("spread"),
            exit_available_size=exit_quote.get("available_size"),
        )
        broker.trades.append(trade)

        # Record P&L event for trading stats (flip = close old position)
        broker._record_pnl_event(order.asset, pnl)

        # Create new position in opposite direction
        initial_hwm = self._get_initial_hwm(order.asset, ctx.fill_price)
        initial_lwm = self._get_initial_lwm(order.asset, ctx.fill_price)
        context = self._build_position_context(order)

        broker.positions[order.asset] = Position(
            asset=order.asset,
            quantity=new_qty,
            entry_price=ctx.fill_price,
            entry_time=ctx.current_time,
            context=context,
            multiplier=broker.get_multiplier(order.asset),
            entry_commission=open_commission,
            entry_slippage=ctx.slippage * (open_qty / ctx.fill_quantity),
            high_water_mark=initial_hwm,
            low_water_mark=initial_lwm,
        )
        broker._positions_created_this_bar.add(order.asset)

        # Cancel all other pending orders for this asset
        for pending_order in list(broker.pending_orders):
            if pending_order.asset == order.asset and pending_order.order_id != order.order_id:
                pending_order.status = OrderStatus.CANCELLED
                broker.pending_orders.remove(pending_order)

        return total_commission

    def _scale_position(
        self, ctx: FillContext, pos: Position, old_qty: float, new_qty: float
    ) -> None:
        """Scale an existing position up or down.

        Args:
            ctx: Fill context
            pos: Position being scaled
            old_qty: Original position quantity
            new_qty: New position quantity (same sign)
        """
        broker = self.broker

        if abs(new_qty) < abs(old_qty):
            # Scaling down - this is a partial exit, calculate and record P&L
            exited_qty = abs(old_qty) - abs(new_qty)

            # Calculate P&L for the exited portion (include multiplier for futures)
            # For long positions: pnl = (exit_price - entry_price) * exited_qty * multiplier
            # For short positions: pnl = (entry_price - exit_price) * exited_qty * multiplier
            if old_qty > 0:  # Long position
                pnl = (ctx.fill_price - pos.entry_price) * exited_qty * pos.multiplier
            else:  # Short position
                pnl = (pos.entry_price - ctx.fill_price) * exited_qty * pos.multiplier

            # Subtract proportional commission
            # entry_commission is for the full position, so we take the proportional part
            exit_portion_ratio = exited_qty / abs(pos.initial_quantity or old_qty)
            proportional_entry_commission = pos.entry_commission * exit_portion_ratio
            partial_exit_commission = ctx.commission
            total_commission = proportional_entry_commission + partial_exit_commission
            pnl -= total_commission

            # Record P&L event for trading stats
            broker._record_pnl_event(ctx.order.asset, pnl)

        elif abs(new_qty) > abs(old_qty):
            # Scaling up - recalculate average entry price
            total_cost = pos.entry_price * abs(old_qty) + ctx.fill_price * abs(ctx.signed_qty)
            pos.entry_price = total_cost / abs(new_qty)
            # Accumulate entry-side costs so eventual close trade includes all entry legs.
            pos.entry_commission += ctx.commission
            if abs(new_qty) > 0:
                total_entry_slippage = pos.entry_slippage * abs(old_qty) + ctx.slippage * abs(
                    ctx.signed_qty
                )
                pos.entry_slippage = total_entry_slippage / abs(new_qty)

        pos.quantity = new_qty

    def _sync_account_state(self, asset: str, current_price: float | None = None) -> None:
        """Sync broker position to AccountState.

        Args:
            asset: Asset to sync
            current_price: Optional mark price for account sync; defaults to latest
                broker close price when not provided.
        """
        broker = self.broker
        broker_pos = broker.positions.get(asset)

        if broker_pos is None:
            # Position was closed, remove from account
            if asset in broker.account.positions:
                del broker.account.positions[asset]
        else:
            # Update or create position in account (include multiplier for correct valuation)
            account_pos = broker.account.positions.get(asset)
            mark_price = (
                current_price
                if current_price is not None
                else broker.get_mark_price(asset, quantity=broker_pos.quantity)
                or broker_pos.entry_price
            )
            if account_pos is None:
                broker.account.positions[asset] = Position(
                    asset=broker_pos.asset,
                    quantity=broker_pos.quantity,
                    entry_price=broker_pos.entry_price,
                    current_price=mark_price,
                    entry_time=broker_pos.entry_time,
                    bars_held=broker_pos.bars_held,
                    multiplier=broker_pos.multiplier,
                )
            else:
                account_pos.quantity = broker_pos.quantity
                account_pos.entry_price = broker_pos.entry_price
                account_pos.current_price = mark_price
                account_pos.entry_time = broker_pos.entry_time
                account_pos.bars_held = broker_pos.bars_held
                account_pos.multiplier = broker_pos.multiplier
