"""
Backtest runner — sets up Nautilus BacktestEngine and runs a PandasStrategy.

Entry points
------------
run_backtest()             — called from execute_strategy MCP tool (via thread executor)
_load_strategy_class()     — exec() the user's implementation.py, find PandasStrategy subclass
_setup_custom_indicators() — register user indicators with pandas-ta via ta.import_dir()
_compute_metrics()         — extract P&L metrics from completed BacktestEngine
"""

from __future__ import annotations

import inspect
import logging
from pathlib import Path
from typing import Any

import pandas as pd

log = logging.getLogger(__name__)


# ---------------------------------------------------------------------------
# Custom indicator setup
# ---------------------------------------------------------------------------

def _setup_custom_indicators(data_dir: Path) -> None:
    """Register user's custom indicators with pandas-ta (delegates to python_tools)."""
    from dexorder.tools.python_tools import setup_custom_indicators
    setup_custom_indicators(data_dir)


# ---------------------------------------------------------------------------
# Strategy class loading
# ---------------------------------------------------------------------------

def _load_strategy_class(impl_path: Path) -> type:
    """
    Execute implementation.py and return the unique PandasStrategy subclass.

    The exec namespace is seeded with:
        PandasStrategy — base class (so the subclass check works)
        pd             — pandas
        ta             — pandas_ta (if available)

    Raises:
        ValueError: if zero or multiple PandasStrategy subclasses are defined
        SyntaxError / Exception: if the file fails to parse or execute
    """
    from dexorder.nautilus.pandas_strategy import PandasStrategy

    namespace: dict[str, Any] = {
        "__builtins__": __builtins__,
        "PandasStrategy": PandasStrategy,
        "pd": pd,
    }

    try:
        import pandas_ta as ta
        namespace["ta"] = ta
    except ImportError:
        pass

    code = impl_path.read_text()
    exec(compile(code, str(impl_path), "exec"), namespace)  # noqa: S102

    subclasses = [
        obj for obj in namespace.values()
        if (
            inspect.isclass(obj)
            and issubclass(obj, PandasStrategy)
            and obj is not PandasStrategy
        )
    ]

    if len(subclasses) == 0:
        raise ValueError(
            f"No PandasStrategy subclass found in {impl_path}. "
            "The strategy file must define exactly one class that inherits from PandasStrategy."
        )
    if len(subclasses) > 1:
        names = [c.__name__ for c in subclasses]
        raise ValueError(
            f"Multiple PandasStrategy subclasses found in {impl_path}: {names}. "
            "Define exactly one concrete strategy class per file."
        )

    return subclasses[0]


# ---------------------------------------------------------------------------
# Metrics extraction
# ---------------------------------------------------------------------------

def _money_to_float(val) -> float | None:
    """Convert a Nautilus Money object or string like '15.32 USDT' to float."""
    if val is None:
        return None
    try:
        if hasattr(val, "as_decimal"):
            return float(val.as_decimal())
        s = str(val).strip()
        if s and s.lower() not in ("none", "nan"):
            return float(s.split()[0])
    except (ValueError, TypeError, IndexError):
        pass
    return None


def _ts_to_s(raw) -> int | None:
    """Convert a Nautilus nanosecond timestamp to Unix seconds."""
    try:
        return int(raw) // 1_000_000_000
    except (TypeError, ValueError):
        return None


def _extract_fills(engine) -> pd.DataFrame:
    """Return a sorted fills DataFrame from BacktestEngine, or empty DataFrame."""
    try:
        df = engine.trader.generate_order_fills_report()
        if df is not None and len(df) > 0:
            if "ts_event" in df.columns:
                df = df.sort_values("ts_event")
            return df
    except Exception as exc:
        log.debug("generate_order_fills_report() failed: %s", exc)
    return pd.DataFrame()


def _extract_trades(fills_df: pd.DataFrame, initial_capital: float) -> list[dict]:
    """
    Pair fills into round-trip trades: buy → sell or sell → buy.
    Returns a list of trade dicts (capped at 500 for large backtests).
    """
    if fills_df.empty:
        return []

    trades: list[dict] = []
    open_positions: dict[str, dict] = {}  # instrument_id -> pending entry

    for _, fill in fills_df.iterrows():
        instrument = str(fill.get("instrument_id", ""))
        side = str(fill.get("order_side", "")).upper()
        qty = _money_to_float(fill.get("last_qty")) or 0.0
        price = _money_to_float(fill.get("last_px")) or 0.0
        ts_s = _ts_to_s(fill.get("ts_event"))
        rpnl = _money_to_float(fill.get("realized_pnl"))

        if rpnl is not None and rpnl != 0.0:
            # This fill closes a position — record as a completed trade
            entry = open_positions.pop(instrument, None)
            trade = {
                "instrument": instrument,
                "side": side,
                "quantity": round(qty, 8),
                "entry_price": round(entry["price"], 8) if entry else None,
                "exit_price": round(price, 8),
                "entry_time": entry["ts_s"] if entry else None,
                "exit_time": ts_s,
                "pnl": round(rpnl, 6),
            }
            trades.append(trade)
        else:
            # Opening fill — store for pairing
            open_positions[instrument] = {"price": price, "ts_s": ts_s, "side": side}

    return trades[:500]  # cap for large backtests


def _compute_metrics(
    engine,
    venue_strs: list[str],
    initial_capital: float,
    all_bars: list,
) -> dict[str, Any]:
    """
    Extract structured performance metrics from a completed BacktestEngine.

    Returns dict with:
        summary      dict  — core metrics (total_return, sharpe, drawdown, etc.)
        statistics   dict  — extended stats (sortino, calmar, profit_factor, etc.)
        trades       list  — individual round-trip trades (capped at 500)
        equity_curve list[{timestamp: int_unix_s, equity: float}]
    """
    fills_df = _extract_fills(engine)
    trades = _extract_trades(fills_df, initial_capital)

    # --- Equity curve reconstruction ---
    equity_points: list[dict] = []
    if all_bars:
        equity_points.append({
            "timestamp": all_bars[0].ts_event // 1_000_000_000,
            "equity": initial_capital,
        })

    running_equity = initial_capital
    trade_count = 0
    winning_trades = 0
    total_profit = 0.0
    total_loss = 0.0

    if not fills_df.empty:
        for _, fill in fills_df.iterrows():
            rpnl = _money_to_float(fill.get("realized_pnl"))
            if rpnl is None or rpnl == 0.0:
                continue
            ts_s = _ts_to_s(fill.get("ts_event"))
            running_equity += rpnl
            trade_count += 1
            if rpnl > 0:
                winning_trades += 1
                total_profit += rpnl
            else:
                total_loss += abs(rpnl)
            if ts_s is not None:
                equity_points.append({"timestamp": ts_s, "equity": running_equity})

    if all_bars:
        equity_points.append({
            "timestamp": all_bars[-1].ts_event // 1_000_000_000,
            "equity": running_equity,
        })

    # Prefer definitive final balance from account cache
    try:
        from nautilus_trader.model.identifiers import Venue
        for venue_str in venue_strs:
            account = engine.cache.account_for_venue(Venue(venue_str))
            if account is None:
                continue
            for bal in account.balances().values():
                final_val = _money_to_float(getattr(bal, "total", None))
                if final_val is not None:
                    running_equity = final_val
                    if equity_points:
                        equity_points[-1]["equity"] = running_equity
                    break
    except Exception as exc:
        log.debug("Account balance extraction failed: %s", exc)

    # --- Core metrics ---
    total_return = (running_equity - initial_capital) / initial_capital if initial_capital else 0.0
    win_rate = winning_trades / trade_count if trade_count > 0 else 0.0
    profit_factor = (total_profit / total_loss) if total_loss > 0 else (float("inf") if total_profit > 0 else 0.0)

    # Determine bar duration for annualisation
    bar_duration_ns = 0.0
    if all_bars and len(all_bars) > 1:
        bar_duration_ns = (all_bars[-1].ts_event - all_bars[0].ts_event) / max(len(all_bars) - 1, 1)
    bars_per_year = (365 * 24 * 3600 * 1e9) / bar_duration_ns if bar_duration_ns > 0 else 0.0

    equity_series = pd.Series([p["equity"] for p in equity_points]) if len(equity_points) > 2 else pd.Series([initial_capital, running_equity])
    returns = equity_series.pct_change().dropna()

    sharpe = 0.0
    sortino = 0.0
    if len(returns) > 1 and bars_per_year > 0:
        mean_r = returns.mean()
        std_r = returns.std()
        if std_r > 0:
            sharpe = float((mean_r / std_r) * (bars_per_year ** 0.5))
        downside = returns[returns < 0]
        downside_std = downside.std() if len(downside) > 1 else 0.0
        if downside_std > 0:
            sortino = float((mean_r / downside_std) * (bars_per_year ** 0.5))

    # Max drawdown
    max_drawdown = 0.0
    if len(equity_series) > 1:
        rolling_max = equity_series.cummax()
        drawdowns = (equity_series - rolling_max) / rolling_max.replace(0, float("nan"))
        max_drawdown = float(abs(drawdowns.min())) if len(drawdowns) > 0 else 0.0

    # Calmar ratio
    annualized_return = 0.0
    if bars_per_year > 0 and len(all_bars) > 1:
        years = (all_bars[-1].ts_event - all_bars[0].ts_event) / (365 * 24 * 3600 * 1e9)
        if years > 0:
            annualized_return = (running_equity / initial_capital) ** (1.0 / years) - 1 if initial_capital else 0.0
    calmar = annualized_return / max_drawdown if max_drawdown > 0 else 0.0

    # Average win / average loss
    avg_win = total_profit / winning_trades if winning_trades > 0 else 0.0
    avg_loss = total_loss / (trade_count - winning_trades) if (trade_count - winning_trades) > 0 else 0.0

    return {
        "summary": {
            "total_return":   round(total_return, 6),
            "sharpe_ratio":   round(sharpe, 4),
            "max_drawdown":   round(max_drawdown, 6),
            "win_rate":       round(win_rate, 4),
            "trade_count":    trade_count,
            "total_trades":   len(trades),
        },
        "statistics": {
            "sortino_ratio":  round(sortino, 4),
            "calmar_ratio":   round(calmar, 4),
            "profit_factor":  round(profit_factor, 4) if profit_factor != float("inf") else None,
            "avg_win":        round(avg_win, 4),
            "avg_loss":       round(avg_loss, 4),
            "total_profit":   round(total_profit, 4),
            "total_loss":     round(total_loss, 4),
        },
        "trades":       trades,
        "equity_curve": equity_points,
    }


# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------

def run_backtest(
    strategy_class: type,
    feeds: list[tuple[str, int]],
    ohlc_dfs: dict[str, pd.DataFrame],
    initial_capital: float = 10_000.0,
    paper: bool = True,
) -> dict[str, Any]:
    """
    Configure and run a BacktestEngine synchronously.

    Designed to be called from asyncio via loop.run_in_executor() since
    BacktestEngine does not support async.

    Args:
        strategy_class: Concrete PandasStrategy subclass to instantiate
        feeds: List of (ticker, period_seconds) pairs, e.g. [("BTC/USDT.BINANCE", 3600)]
        ohlc_dfs: Dict of feed_key → full OHLC+ DataFrame (with buy_vol, sell_vol, etc.)
        initial_capital: Starting account balance in quote currency
        paper: Always True for historical backtest (flag reserved for forward testing)

    Returns:
        Dict of performance metrics (see _compute_metrics)
    """
    from nautilus_trader.backtest.engine import BacktestEngine, BacktestEngineConfig
    from nautilus_trader.backtest.models import FillModel
    from nautilus_trader.config import LoggingConfig
    from nautilus_trader.model.enums import OmsType, AccountType
    from nautilus_trader.model.identifiers import Venue
    from nautilus_trader.model.objects import Money

    from dexorder.nautilus.pandas_strategy import PandasStrategyConfig, make_feed_key
    from dexorder.nautilus.data_adapter import (
        make_instrument_from_metadata,
        make_bar_type,
        df_to_bars,
        extras_lookup,
    )

    # --- Engine config ---
    engine_config = BacktestEngineConfig(
        trader_id="DEXORDER-BACKTEST-001",
        logging=LoggingConfig(log_level="ERROR"),
    )
    engine = BacktestEngine(config=engine_config)

    # --- Per-venue setup (unique venues from feeds) ---
    venues_seen: set[str] = set()
    all_bars: list = []
    feed_keys: list[str] = []

    instruments: dict[str, Any] = {}
    price_precisions: dict[str, int] = {}
    size_precisions: dict[str, int] = {}

    for ticker, period_seconds in feeds:
        feed_key = make_feed_key(ticker, period_seconds)
        feed_keys.append(feed_key)

        from dexorder.symbol_metadata_client import parse_ticker
        exchange_id, _ = parse_ticker(ticker)

        if exchange_id not in venues_seen:
            venues_seen.add(exchange_id)
            # Determine quote currency from ticker (e.g. USDT from BTC/USDT)
            _, market_id = parse_ticker(ticker)
            quote_str = market_id.split("/")[1] if "/" in market_id else "USDT"
            from nautilus_trader.model.currencies import Currency
            quote_currency = Currency.from_str(quote_str)
            engine.add_venue(
                venue=Venue(exchange_id),
                oms_type=OmsType.NETTING,
                account_type=AccountType.CASH,
                base_currency=None,
                starting_balances=[Money(initial_capital, quote_currency)],
                fill_model=FillModel(),
            )

        # Instrument and bars
        instrument, pp, sp = make_instrument_from_metadata(ticker)
        instruments[feed_key] = instrument
        price_precisions[feed_key] = pp
        size_precisions[feed_key] = sp

        engine.add_instrument(instrument)

        df = ohlc_dfs.get(feed_key)
        if df is not None and not df.empty:
            bar_type = make_bar_type(ticker, period_seconds)
            bars = df_to_bars(df, bar_type, pp, sp)
            engine.add_data(bars)
            all_bars.extend(bars)
        else:
            log.warning("No OHLC data for feed %s — strategy will receive no bars", feed_key)

    if not all_bars:
        return {
            "total_return": 0.0, "sharpe_ratio": 0.0, "max_drawdown": 0.0,
            "win_rate": 0.0, "trade_count": 0, "equity_curve": [],
        }

    # Sort combined bars by timestamp for metrics computation
    all_bars.sort(key=lambda b: b.ts_event)

    # --- Instantiate and configure strategy ---
    strategy_config = PandasStrategyConfig(
        strategy_id=f"{strategy_class.__name__}-001",
        feed_keys=tuple(feed_keys),
        initial_capital=initial_capital,
    )
    strategy = strategy_class(config=strategy_config)

    # Inject OHLC+ extras before run
    for feed_key, df in ohlc_dfs.items():
        if df is not None and not df.empty:
            strategy._inject_extras(feed_key, extras_lookup(df))

    engine.add_strategy(strategy)

    # --- Run ---
    engine.run()

    # --- Extract metrics ---
    metrics = _compute_metrics(engine, list(venues_seen), initial_capital, all_bars)
    engine.dispose()

    return metrics
