# src/backtest/engine.py
"""
Motor de backtesting para simular estrategias de trading
"""
import pandas as pd
import numpy as np
from typing import Optional, List
from datetime import datetime
from ..utils.logger import log
from .strategy import Strategy, Signal
from .trade import Trade, TradeType, TradeStatus, Position
from ..risk.sizing.base import PositionSizer
from ..risk.stops.base import StopLoss

class Engine:
    """
    Motor de backtesting que simula la ejecución de una estrategia
    """
    
    def __init__(
        self,
        strategy: Strategy,
        initial_capital: float = 10000,
        commission: float = 0.001,  # 0.1% por trade
        slippage: float = 0.0005,   # 0.05% de slippage
        position_size: float = 1.0,   # Fracción del capital por trade (1.0 = 100%)
        position_sizer: Optional[PositionSizer] = None,
        stop_loss: Optional[StopLoss] = None,
    ):
        """
        Inicializa el motor de backtesting
        
        Args:
            strategy: Estrategia a testear
            initial_capital: Capital inicial
            commission: Comisión por trade (como fracción, ej: 0.001 = 0.1%)
            slippage: Slippage simulado (como fracción)
            position_size: Fracción del capital a usar por trade
            position_sizer: Objeto PositionSizer para calcular units dinámicamente
        """
        self.strategy = strategy
        self.initial_capital = initial_capital
        self.commission = commission
        self.slippage = slippage
        self.position_size_fraction = position_size
        self.position_sizer = position_sizer
        self.stop_loss = stop_loss
        
        # Estado del backtest
        self.cash = initial_capital
        self.equity = initial_capital
        self.trades: List[Trade] = []
        self.current_position: Optional[Position] = None
        
        # Historial para métricas
        self.equity_curve: List[float] = []
        self.timestamps: List[datetime] = []
        self.drawdown_curve: List[float] = []
        
        # Datos
        self.data: Optional[pd.DataFrame] = None
        
        log.info(f"Backtesting Engine inicializado: {strategy}")
        log.info(f"Capital inicial: ${initial_capital:,.2f}")
        log.info(f"Comisión: {commission*100:.2%}, Slippage: {slippage*100:.2%}")

        if self.position_sizer is not None:
            log.info(f"PositionSizer activo: {self.position_sizer.__class__.__name__}")
        else:
            log.info(f"PositionSizer: None (fallback position_size={self.position_size_fraction:.2f})")
    
    def run(self, data: pd.DataFrame) -> dict:
        """
        Ejecuta el backtest sobre los datos históricos
        
        Args:
            data: DataFrame con datos OHLCV
            
        Returns:
            Diccionario con resultados del backtest
        """
        log.info("="*70)
        log.info("INICIANDO BACKTEST")
        log.info("="*70)
        
        # Reset state
        self._reset()
        
        # Establecer datos en la estrategia
        self.data = data.copy()
        self.strategy.set_data(self.data)
        
        log.info(f"Datos: {len(data)} velas")
        log.info(f"Periodo: {data.index[0]} a {data.index[-1]}")
        log.info(f"Estrategia: {self.strategy}")
        
        # Iterar por cada timestamp
        for idx in range(len(self.data)):
            self._process_bar(idx)
        
        # Cerrar posición abierta al final
        if self.current_position is not None:
            self._close_position(len(self.data) - 1, "End of backtest")
        
        # Calcular resultados
        results = self._calculate_results()
        
        log.info("="*70)
        log.success("BACKTEST COMPLETADO")
        log.info("="*70)
        
        return results
    
    def _reset(self):
        """Reinicia el estado del backtest"""
        self.cash = self.initial_capital
        self.equity = self.initial_capital
        self.trades = []
        self.current_position = None
        self.equity_curve = []
        self.timestamps = []
        self.drawdown_curve = []
    
    def _process_bar(self, idx: int):
        """
        Procesa una barra de datos (timestamp)
        
        Args:
            idx: Índice de la barra actual
        """
        current_bar = self.data.iloc[idx]
        current_price = current_bar['close']
        current_time = current_bar.name  # El índice es el timestamp
        
        # 🟠 ACTUALIZAR TRAILING STOP (si aplica)
        if self.current_position is not None and self.stop_loss is not None:
            try:
                new_stop = self.stop_loss.update_stop(
                    data=self.data,
                    idx=idx,
                    position=self.current_position,
                )

                if new_stop is not None:
                    self.current_position.move_stop(new_stop)

            except Exception as e:
                log.warning(f"[{current_time}] Error actualizando stop: {e}")

        # 🔴 STOP LOSS CHECK
        if self.current_position is not None:
            if self.current_position.is_stop_hit(current_price):
                unrealized = self.current_position.unrealized_pnl(current_price)

                log.info(
                    f"[{current_time}] 🔴 STOP HIT | "
                    f"{self.current_position.trade_type.value} {self.current_position.symbol} | "
                    f"Price: {current_price:.2f} | "
                    f"Stop: {self.current_position.stop_price:.2f} | "
                    f"Unrealized PnL: {unrealized:.2f}"
                )

                self._close_position(idx, reason="Stop Loss")
                self._update_equity(idx)
                return

        # 📈 Actualizar equity
        self._update_equity(idx)

        # 📊 Guardar historial
        self.equity_curve.append(self.equity)
        self.timestamps.append(current_time)

        # 📡 Generar señal
        signal = self.strategy.generate_signal(idx)

        if signal == Signal.BUY and self.current_position is None:
            self._open_position(idx, TradeType.LONG)

        elif signal == Signal.SELL and self.current_position is not None:
            self._close_position(idx, "Strategy signal")
    
    def _open_position(self, idx: int, trade_type: TradeType):
        """
        Abre una nueva posición, delegando size al PositionSizer si existe
        """
        current_bar = self.data.iloc[idx]
        current_price = current_bar["close"]
        current_time = current_bar.name

        # Aplicar slippage (en compra pagamos más)
        execution_price = current_price * (1 + self.slippage)

        # --------------------------------------------------
        # 🔴 1) Calcular STOP antes del size (si existe)
        # --------------------------------------------------
        stop_price = None
        if self.stop_loss is not None:
            try:
                stop_price = self.stop_loss.get_stop_price(
                    data=self.data,
                    idx=idx,
                    entry_price=execution_price,
                    trade_type=trade_type,
                )
            except Exception as e:
                log.warning(f"[{current_time}] Error calculando stop: {e}")
                return

        # --------------------------------------------------
        # ✅ 2) Calcular units (size)
        # --------------------------------------------------
        if self.position_sizer is not None:
            try:
                units = float(
                    self.position_sizer.calculate_size(
                        capital=self.cash,
                        entry_price=float(execution_price),
                        stop_price=stop_price,
                        max_capital=self.cash,
                        volatility=None,
                    )
                )
            except Exception as e:
                log.warning(f"[{current_time}] PositionSizer rechazó la entrada: {e}")
                return

            if not np.isfinite(units) or units <= 0:
                log.warning(f"[{current_time}] Units inválidas: {units}")
                return

        else:
            # Fallback legacy
            units = (self.cash * self.position_size_fraction) / execution_price

        # --------------------------------------------------
        # ✅ 3) CLIP SIZE si no hay suficiente cash
        # --------------------------------------------------
        position_value = units * execution_price
        commission_cost = position_value * self.commission
        total_cost = position_value + commission_cost

        if total_cost > self.cash:
            max_affordable_units = self.cash / (
                execution_price * (1 + self.commission)
            )

            if max_affordable_units <= 0:
                log.warning(
                    f"[{current_time}] Cash insuficiente incluso para size mínimo"
                )
                return

            units = max_affordable_units
            position_value = units * execution_price
            commission_cost = position_value * self.commission

        # --------------------------------------------------
        # ✅ 4) Crear trade
        # --------------------------------------------------
        trade = Trade(
            symbol=current_bar.get("symbol", "UNKNOWN"),
            trade_type=trade_type,
            entry_price=execution_price,
            entry_time=current_time,
            size=units,
            entry_commission=commission_cost,
            entry_reason="Strategy signal",
            stop_price_at_entry=stop_price,
            capital_at_entry=self.cash,
        )

        # Actualizar cash
        self.cash -= (position_value + commission_cost)

        # --------------------------------------------------
        # ✅ 5) Crear posición
        # --------------------------------------------------
        self.current_position = Position(
            symbol=trade.symbol,
            trade_type=trade_type,
            average_price=execution_price,
            total_size=units,
            trades=[trade],
        )

        # Fijar stop inicial (si existe)
        if stop_price is not None:
            self.current_position.set_stop(stop_price)

        self.trades.append(trade)

        log.debug(
            f"[{current_time}] OPEN {trade_type.value}: "
            f"Price=${execution_price:.2f} | Units={units:.6f} | "
            f"Value=${position_value:.2f} | Fee=${commission_cost:.2f}"
        )


    def _close_position(self, idx: int, reason: str):
        """
        Cierra la posición actual
        """
        if self.current_position is None:
            return

        current_bar = self.data.iloc[idx]
        current_price = current_bar["close"]
        current_time = current_bar.name

        # Aplicar slippage (en venta, recibimos menos)
        execution_price = current_price * (1 - self.slippage)

        # Valor de la posición
        position_value = self.current_position.total_size * execution_price

        # Calcular comisión
        commission_cost = position_value * self.commission

        # Cerrar todos los trades de la posición
        for trade in self.current_position.trades:
            trade.exit_commission = commission_cost / len(self.current_position.trades)
            trade.close(execution_price, current_time, reason)

        # Actualizar cash
        self.cash += (position_value - commission_cost)

        # Calcular PnL de la posición
        total_pnl = sum(t.pnl for t in self.current_position.trades)

        log.debug(
            f"[{current_time}] CLOSE {self.current_position.trade_type.value}: "
            f"Price: ${execution_price:.2f}, PnL: ${total_pnl:.2f} "
            f"({total_pnl/self.initial_capital*100:.2f}%)"
        )

        # Limpiar posición actual
        self.current_position = None

    def _update_equity(self, idx: int):
        """
        Actualiza el equity total (cash + valor de posiciones abiertas)
        """
        self.equity = self.cash

        if self.current_position is not None:
            current_price = self.data.iloc[idx]["close"]
            position_value = self.current_position.total_size * current_price
            self.equity += position_value

    def _calculate_results(self) -> dict:
        """
        Calcula métricas y resultados del backtest
        """
        closed_trades = [t for t in self.trades if t.status == TradeStatus.CLOSED]

        if not closed_trades:
            log.warning("No hay trades cerrados para analizar")
            return self._empty_results()

        # Métricas básicas
        total_trades = len(closed_trades)
        winning_trades = [t for t in closed_trades if t.pnl > 0]
        losing_trades = [t for t in closed_trades if t.pnl < 0]

        win_rate = len(winning_trades) / total_trades if total_trades > 0 else 0

        total_pnl = sum(t.pnl for t in closed_trades)
        total_return = (self.equity - self.initial_capital) / self.initial_capital

        # Profit factor
        gross_profit = sum(t.pnl for t in winning_trades)
        gross_loss = abs(sum(t.pnl for t in losing_trades))
        profit_factor = gross_profit / gross_loss if gross_loss > 0 else float("inf")

        # Drawdown
        equity_curve = np.array(self.equity_curve)
        running_max = np.maximum.accumulate(equity_curve)
        drawdown = (equity_curve - running_max) / running_max
        max_drawdown = drawdown.min()

        # Sharpe Ratio (aproximado con returns diarios)
        returns = pd.Series(equity_curve).pct_change().dropna()
        if returns.std() == 0 or np.isnan(returns.std()):
            sharpe_ratio = 0.0
        else:
            sharpe_ratio = (returns.mean() / returns.std()) * np.sqrt(252) if len(returns) > 1 else 0

        results = {
            # Generales
            "initial_capital": self.initial_capital,
            "final_equity": self.equity,
            "total_return": total_return,
            "total_return_pct": total_return * 100,

            # Trades
            "total_trades": total_trades,
            "winning_trades": len(winning_trades),
            "losing_trades": len(losing_trades),
            "win_rate": win_rate,
            "win_rate_pct": win_rate * 100,

            # PnL
            "total_pnl": total_pnl,
            "gross_profit": gross_profit,
            "gross_loss": gross_loss,
            "profit_factor": profit_factor,

            # Average trades
            "avg_win": np.mean([t.pnl for t in winning_trades]) if winning_trades else 0,
            "avg_loss": np.mean([t.pnl for t in losing_trades]) if losing_trades else 0,
            "avg_trade": total_pnl / total_trades if total_trades > 0 else 0,

            # Risk metrics
            "max_drawdown": max_drawdown,
            "max_drawdown_pct": max_drawdown * 100,
            "sharpe_ratio": sharpe_ratio,

            # Datos para gráficos
            "equity_curve": self.equity_curve,
            "timestamps": self.timestamps,
            "trades": closed_trades,
        }

        return results

    def _empty_results(self) -> dict:
        """Resultados vacíos cuando no hay trades"""
        return {
            "initial_capital": self.initial_capital,
            "final_equity": self.equity,
            "total_return": 0,
            "total_return_pct": 0,
            "total_trades": 0,
            "winning_trades": 0,
            "losing_trades": 0,
            "win_rate": 0,
            "win_rate_pct": 0,
            "total_pnl": 0,
            "gross_profit": 0,
            "gross_loss": 0,
            "profit_factor": 0,
            "avg_win": 0,
            "avg_loss": 0,
            "avg_trade": 0,
            "max_drawdown": 0,
            "max_drawdown_pct": 0,
            "sharpe_ratio": 0,
            "equity_curve": self.equity_curve,
            "timestamps": self.timestamps,
            "trades": [],
        }
