"""Backtesting engine - replay historical data through the same pipeline as live trading."""

from __future__ import annotations

import csv
import logging
import math
import os
from dataclasses import dataclass, replace
from typing import Any, Callable

from horizon._horizon import Engine, Market, RiskConfig
from horizon.context import Context, FeedData, InventorySnapshot
from horizon.result import BacktestResult
from horizon.risk import Risk
from horizon.strategy import _run_pipeline, _process_result

logger = logging.getLogger("horizon.backtest")


@dataclass
class Tick:
    """A single data point in the backtest timeline."""

    timestamp: float
    price: float = 0.0
    bid: float = 0.0
    ask: float = 0.0
    volume: float = 0.0


def backtest(
    name: str = "backtest",
    markets: list[str] | None = None,
    data: Any = None,
    feeds: dict[str, Any] | None = None,
    pipeline: list[Callable] | None = None,
    risk: Risk | RiskConfig | None = None,
    params: dict[str, Any] | None = None,
    paper_fee_rate: float = 0.001,
    paper_maker_fee_rate: float | None = None,
    paper_taker_fee_rate: float | None = None,
    initial_capital: float = 1000.0,
    outcomes: dict[str, float] | None = None,
    book_data: dict[str, list[dict]] | None = None,
    fill_model: str = "deterministic",
    fill_model_params: dict[str, float] | None = None,
    impact_temporary_bps: float = 0.0,
    impact_permanent_fraction: float = 0.0,
    latency_ms: float = 0.0,
    rng_seed: int | None = None,
    price_range: tuple[float, float] | None = None,
) -> BacktestResult:
    """Run a backtest over historical data using the same pipeline as hz.run().

    Args:
        name: Strategy name (for logging).
        markets: List of market IDs to quote on. Defaults to ["market"].
        data: Historical data - list[dict], CSV path (str), pandas DataFrame,
              or dict[feed_name → data] for multi-feed backtests.
        feeds: Feed name mapping when using a single data source.
               e.g., {"btc": None} maps the data to feed name "btc".
               If not provided and data is not a dict, defaults to {"default": data}.
        pipeline: List of pipeline functions (same as hz.run()).
        risk: Risk config.
        params: Arbitrary parameters passed to Context.
        paper_fee_rate: Fee rate for the paper exchange.
        paper_maker_fee_rate: Maker fee rate (overrides paper_fee_rate for maker fills).
        paper_taker_fee_rate: Taker fee rate (overrides paper_fee_rate for taker fills).
        initial_capital: Starting capital for equity tracking.
        outcomes: dict of market_id → outcome (1.0 or 0.0) for Brier score.
        book_data: L2 orderbook data per market: {market_id: [{timestamp, bids, asks}, ...]}.
        fill_model: "deterministic", "probabilistic", or "glft".
        fill_model_params: Fill model parameters (lambda, queue_frac, intensity, kappa).
        impact_temporary_bps: Temporary market impact in basis points.
        impact_permanent_fraction: Fraction of temporary impact that persists.
        latency_ms: Simulated order latency in milliseconds.
        rng_seed: Random seed for stochastic fill models.
        price_range: Valid price range as (min, max). Default (0, 1] for prediction markets.
                     Set to e.g. (0, 1e6) for equities/crypto.

    Returns:
        BacktestResult with equity curve, trades, positions, and metrics.
    """
    from horizon._horizon import auth_require_pro
    auth_require_pro()

    if pipeline is None or len(pipeline) == 0:
        raise ValueError("pipeline must contain at least one function")
    if markets is None:
        markets = ["market"]
    if params is None:
        params = {}

    # Resolve feed data from inputs
    feed_ticks = _resolve_feed_data(data, feeds, markets)

    # Validate all tick streams (skip empty feeds)
    price_min, price_max = price_range if price_range else (0.0, 1.0)
    has_data = False
    for feed_name, ticks in feed_ticks.items():
        if ticks:
            _validate_ticks(ticks, feed_name, price_min=price_min, price_max=price_max)
            has_data = True

    # Build merged timeline
    timeline = _build_timeline(feed_ticks) if has_data else []

    if not timeline:
        return BacktestResult(
            equity_curve=[(0.0, initial_capital)],
            initial_capital=initial_capital,
            outcomes=outcomes,
        )

    # Build risk config - auto-relax rate limits for backtest speed
    risk_config = _build_risk_config(risk)

    # Determine if we should use BookSim exchange
    use_book_sim = book_data is not None or fill_model != "deterministic"

    # Build book timeline if book_data provided
    book_timeline = _build_book_timeline(book_data) if book_data else None

    # Compute latency_ticks from latency_ms
    latency_ticks = 0
    if latency_ms > 0 and timeline:
        timestamps = [ts for ts, _ in timeline]
        if len(timestamps) > 1:
            avg_interval = (timestamps[-1] - timestamps[0]) / (len(timestamps) - 1)
            if avg_interval > 0:
                latency_ticks = max(1, round(latency_ms / 1000.0 / avg_interval))

    # Extract fill model params
    fmp = fill_model_params or {}

    # Create engine
    if use_book_sim:
        engine = Engine(
            risk_config=risk_config,
            paper_fee_rate=paper_fee_rate,
            exchange_type="book_sim",
            fill_model=fill_model,
            fill_lambda=fmp.get("lambda", 1.0),
            fill_kappa=fmp.get("kappa", 1.5),
            fill_queue_position=fmp.get("queue_frac", 0.5),
            fill_intensity=fmp.get("intensity", 1.0),
            impact_temporary_bps=impact_temporary_bps,
            impact_permanent_fraction=impact_permanent_fraction,
            latency_ticks=latency_ticks,
            rng_seed=rng_seed,
            db_path=None,
            paper_maker_fee_rate=paper_maker_fee_rate,
            paper_taker_fee_rate=paper_taker_fee_rate,
        )
    else:
        engine = Engine(
            risk_config=risk_config,
            paper_fee_rate=paper_fee_rate,
            db_path=None,
            paper_maker_fee_rate=paper_maker_fee_rate,
            paper_taker_fee_rate=paper_taker_fee_rate,
        )

    # Set daily baseline
    engine.set_daily_baseline(0.0)

    # Create market objects
    market_objs = [Market(id=slug, name=slug, slug=slug) for slug in markets]

    # Run simulation
    equity_curve: list[tuple[float, float]] = []
    all_fills: list = []
    seen_fill_ids: set[str] = set()
    mid_prices: list[tuple[float, float]] = []  # (timestamp, mid) for adverse selection

    for ts, feed_states in timeline:
        # Inject book state for BookSim exchange before tick
        if book_timeline is not None:
            for market in market_objs:
                book_snap = book_timeline.get(ts, {}).get(market.id)
                if book_snap is not None:
                    engine.update_book(
                        market.id,
                        book_snap["bids"],
                        book_snap["asks"],
                        ts,
                    )

        # Pre-pipeline tick: match resting orders from the previous cycle
        # against this tick's price BEFORE cancel-and-requote.  Without this,
        # cancel-before-requote kills the orders before the new price can fill them.
        for market in market_objs:
            market_price = _get_market_mid(feed_states, market.id)
            if market_price is not None:
                engine.tick(market.id, market_price)

        # Record mid price for adverse selection analysis
        global_mid = _get_feed_mid(feed_states)
        if global_mid is not None:
            mid_prices.append((ts, global_mid))

        # Cache status and positions once per tick (avoids redundant PyO3 crossings)
        cycle_status = engine.status()
        current_pnl = cycle_status.total_realized_pnl + cycle_status.total_unrealized_pnl
        engine.update_daily_pnl(current_pnl)

        for market in market_objs:
            try:
                ctx = _build_backtest_context(engine, market, feed_states, params,
                                              cached_status=cycle_status, sim_time=ts)
                result = _run_pipeline(pipeline, ctx)

                if result is not None:
                    _process_result(engine, market, result, ctx)

                    # Post-submission tick: _process_result does cancel -> tick -> submit,
                    # so newly submitted orders haven't been matched yet. Tick again
                    # at the current feed price to fill the new resting orders.
                    market_price = _get_market_mid(feed_states, market.id)
                    if market_price is not None:
                        engine.tick(market.id, market_price)
            except Exception as e:
                logger.warning("Pipeline error for %s at t=%.2f: %s", market.id, ts, e)

        # Drain fills using fill_id-based dedup (immune to 1000-fill cap)
        for fill in engine.recent_fills():
            if fill.fill_id not in seen_fill_ids:
                seen_fill_ids.add(fill.fill_id)
                all_fills.append(fill)

        # Snapshot equity
        status = engine.status()
        equity = initial_capital + status.total_pnl()
        equity_curve.append((ts, equity))

    return BacktestResult(
        equity_curve=equity_curve,
        trades=all_fills,
        positions=engine.positions(),
        initial_capital=initial_capital,
        outcomes=outcomes,
        mid_prices=mid_prices,
    )


def _normalize_data(data: Any) -> list[Tick]:
    """Convert various data formats to list[Tick].

    Accepts:
        - list[dict] with keys: timestamp, price, [bid, ask, volume]
        - str (CSV file path)
        - pandas DataFrame with columns: timestamp, price, [bid, ask, volume]
    """
    if data is None:
        return []

    if isinstance(data, str):
        return _load_csv(data)

    if isinstance(data, list):
        return _from_dicts(data)

    # Duck-type check for DataFrame (avoid hard pandas dependency)
    if hasattr(data, "iterrows") and hasattr(data, "columns"):
        return _from_dataframe(data)

    raise TypeError(
        f"Unsupported data type: {type(data).__name__}. "
        "Expected list[dict], CSV path (str), or pandas DataFrame."
    )


def _from_dicts(records: list[dict]) -> list[Tick]:
    """Convert list of dicts to list of Tick."""
    ticks = []
    for i, rec in enumerate(records):
        if "timestamp" not in rec:
            raise ValueError(f"Record {i} missing required 'timestamp' field")
        if "price" not in rec and "bid" not in rec:
            raise ValueError(f"Record {i} missing 'price' or 'bid' field")

        price = float(rec.get("price", 0.0))
        bid = float(rec.get("bid", 0.0))
        ask = float(rec.get("ask", 0.0))

        # If only price is given, derive bid/ask
        if price > 0 and bid == 0 and ask == 0:
            bid = price
            ask = price
        # If only bid/ask given, derive price as mid
        if price == 0 and bid > 0 and ask > 0:
            price = (bid + ask) / 2.0

        ticks.append(Tick(
            timestamp=float(rec["timestamp"]),
            price=price,
            bid=bid,
            ask=ask,
            volume=float(rec.get("volume", 0.0)),
        ))
    return ticks


def _load_csv(path: str) -> list[Tick]:
    """Load ticks from a CSV file."""
    if not os.path.exists(path):
        raise FileNotFoundError(f"CSV file not found: {path}")

    records = []
    with open(path, newline="") as f:
        reader = csv.DictReader(f)
        for row in reader:
            rec = {}
            for key in ("timestamp", "price", "bid", "ask", "volume"):
                if key in row and row[key]:
                    rec[key] = float(row[key])
            records.append(rec)

    return _from_dicts(records)


def _from_dataframe(df: Any) -> list[Tick]:
    """Convert a pandas DataFrame to list of Tick (duck-typed, no pandas import)."""
    # to_dict("records") is ~10-100x faster than iterrows()
    if hasattr(df, "to_dict"):
        return _from_dicts(df.to_dict("records"))
    # Fallback for non-standard DataFrame-like objects
    records = []
    cols = set(df.columns)
    for _, row in df.iterrows():
        rec = {"timestamp": float(row["timestamp"])}
        if "price" in cols:
            rec["price"] = float(row["price"])
        if "bid" in cols:
            rec["bid"] = float(row["bid"])
        if "ask" in cols:
            rec["ask"] = float(row["ask"])
        if "volume" in cols:
            rec["volume"] = float(row["volume"])
        records.append(rec)
    return _from_dicts(records)


def _validate_ticks(
    ticks: list[Tick],
    feed_name: str = "default",
    price_min: float = 0.0,
    price_max: float = 1.0,
) -> None:
    """Validate tick data: finite values, monotonic timestamps, prices in valid range."""
    if not ticks:
        raise ValueError(f"Feed '{feed_name}' has no data")

    for i, tick in enumerate(ticks):
        # NaN / Inf check
        if not math.isfinite(tick.price) or not math.isfinite(tick.bid) or not math.isfinite(tick.ask):
            raise ValueError(
                f"Feed '{feed_name}' tick {i}: non-finite value detected "
                f"(price={tick.price}, bid={tick.bid}, ask={tick.ask})"
            )
        if tick.price <= price_min or tick.price > price_max:
            raise ValueError(
                f"Feed '{feed_name}' tick {i}: price {tick.price} outside valid range "
                f"({price_min}, {price_max}]."
            )
        if tick.bid < price_min or tick.bid > price_max:
            raise ValueError(
                f"Feed '{feed_name}' tick {i}: bid {tick.bid} outside valid range "
                f"[{price_min}, {price_max}]"
            )
        if tick.ask < price_min or tick.ask > price_max:
            raise ValueError(
                f"Feed '{feed_name}' tick {i}: ask {tick.ask} outside valid range "
                f"[{price_min}, {price_max}]"
            )
        if i > 0 and tick.timestamp < ticks[i - 1].timestamp:
            raise ValueError(
                f"Feed '{feed_name}' tick {i}: timestamps not monotonic "
                f"({tick.timestamp} < {ticks[i - 1].timestamp})"
            )


def _resolve_feed_data(
    data: Any,
    feeds: dict[str, Any] | None,
    markets: list[str],
) -> dict[str, list[Tick]]:
    """Map raw data inputs to dict[feed_name → list[Tick]].

    Handles both single-feed and multi-feed cases.
    """
    # Multi-feed: data is a dict mapping feed names to data sources
    if isinstance(data, dict):
        result = {}
        for feed_name, feed_data in data.items():
            result[feed_name] = _normalize_data(feed_data)
        return result

    # Single data source - map to feed names
    ticks = _normalize_data(data)

    if feeds is not None:
        # Map to each specified feed name
        return {feed_name: list(ticks) for feed_name in feeds}

    # Default: single feed named "default"
    return {"default": ticks}


def _build_timeline(
    feed_ticks: dict[str, list[Tick]],
) -> list[tuple[float, dict[str, FeedData]]]:
    """Merge multi-feed ticks into a single chronological timeline.

    At each unique timestamp, carries forward the latest state of each feed.
    Returns list of (timestamp, {feed_name: FeedData}).
    """
    # Collect all unique timestamps
    all_timestamps: set[float] = set()
    for ticks in feed_ticks.values():
        for tick in ticks:
            all_timestamps.add(tick.timestamp)

    sorted_ts = sorted(all_timestamps)
    if not sorted_ts:
        return []

    # Build index: for each feed, map timestamp -> tick
    feed_indices: dict[str, dict[float, Tick]] = {}
    for feed_name, ticks in feed_ticks.items():
        feed_indices[feed_name] = {t.timestamp: t for t in ticks}

    # Carry-forward state for each feed
    current_state: dict[str, FeedData] = {
        feed_name: FeedData() for feed_name in feed_ticks
    }

    timeline = []
    for ts in sorted_ts:
        for feed_name in feed_ticks:
            if ts in feed_indices[feed_name]:
                tick = feed_indices[feed_name][ts]
                current_state[feed_name] = FeedData(
                    price=tick.price,
                    timestamp=tick.timestamp,
                    bid=tick.bid,
                    ask=tick.ask,
                    volume_24h=tick.volume,
                )

        # Snapshot current state (copy dicts to avoid mutation)
        timeline.append((ts, dict(current_state)))

    return timeline


def _build_backtest_context(
    engine: Engine,
    market: Market,
    feed_states: dict[str, FeedData],
    params: dict[str, Any],
    cached_status: Any = None,
    sim_time: float | None = None,
) -> Context:
    """Build a Context with injected historical feed data (bypasses FeedManager).

    Injects fills_this_cycle, orderbooks, and engine into params to match
    the live _build_context signature (needed by adaptive_spread, arb_scanner, etc.).
    """
    # Shallow copy to avoid cross-market mutation
    ctx_params = dict(params)

    # Inject fills grouped by market (matches _build_context)
    recent = engine.recent_fills()
    fills_this_cycle: dict[str, list] = {}
    for fill in recent:
        mid = getattr(fill, "market_id", "")
        fills_this_cycle.setdefault(mid, []).append(fill)
    ctx_params["fills_this_cycle"] = fills_this_cycle
    ctx_params["orderbooks"] = {}
    ctx_params["engine"] = engine

    # Stamp simulation clock so FeedData.is_stale() uses sim time, not wall clock
    if sim_time is not None:
        feed_states = {
            name: replace(fdata, _reference_time=sim_time)
            for name, fdata in feed_states.items()
        }

    return Context(
        feeds=feed_states,
        inventory=InventorySnapshot(
            positions=engine.positions_for_market(market.id) if market else engine.positions()
        ),
        market=market,
        status=cached_status if cached_status is not None else engine.status(),
        params=ctx_params,
    )


def _get_feed_mid(feed_states: dict[str, FeedData]) -> float | None:
    """Extract a mid price from feed states for paper exchange ticking."""
    for fdata in feed_states.values():
        if fdata.price > 0:
            if fdata.bid > 0 and fdata.ask > 0:
                return (fdata.bid + fdata.ask) / 2.0
            return fdata.price
    return None


def _get_market_mid(feed_states: dict[str, FeedData], market_id: str) -> float | None:
    """Get mid price for a specific market, falling back to first available feed.

    Tries to match feed name to market_id for multi-market backtests.
    """
    # Try exact market_id match first
    if market_id in feed_states:
        fdata = feed_states[market_id]
        if fdata.price > 0:
            if fdata.bid > 0 and fdata.ask > 0:
                return (fdata.bid + fdata.ask) / 2.0
            return fdata.price
    # Fall back to first available feed
    return _get_feed_mid(feed_states)


def _build_book_timeline(
    book_data: dict[str, list[dict]],
) -> dict[float, dict[str, dict]]:
    """Build a timeline of L2 book snapshots with carry-forward.

    Args:
        book_data: {market_id: [{timestamp, bids, asks}, ...]}

    Returns:
        {timestamp: {market_id: {bids: [...], asks: [...]}}}
    """
    # Collect all timestamps
    all_timestamps: set[float] = set()
    for snapshots in book_data.values():
        for snap in snapshots:
            ts = snap.get("timestamp")
            if ts is None:
                continue
            all_timestamps.add(float(ts))

    sorted_ts = sorted(all_timestamps)
    if not sorted_ts:
        return {}

    # Build index: market_id -> {timestamp -> snapshot}
    market_indices: dict[str, dict[float, dict]] = {}
    for market_id, snapshots in book_data.items():
        market_indices[market_id] = {float(s["timestamp"]): s for s in snapshots}

    # Carry-forward state
    current_books: dict[str, dict] = {}
    timeline: dict[float, dict[str, dict]] = {}

    for ts in sorted_ts:
        for market_id in book_data:
            if ts in market_indices[market_id]:
                snap = market_indices[market_id][ts]
                # Normalize bids/asks to list of (price, size) tuples
                bids = [
                    (float(b[0]), float(b[1]))
                    for b in snap.get("bids", [])
                    if len(b) >= 2
                ]
                asks = [
                    (float(a[0]), float(a[1]))
                    for a in snap.get("asks", [])
                    if len(a) >= 2
                ]
                current_books[market_id] = {"bids": bids, "asks": asks}

        if current_books:
            timeline[ts] = dict(current_books)

    return timeline


def _build_risk_config(risk: Risk | RiskConfig | None) -> RiskConfig:
    """Build RiskConfig for backtest - auto-relax rate limits."""
    if isinstance(risk, Risk):
        config = risk.to_config()
    elif isinstance(risk, RiskConfig):
        config = risk
    else:
        config = RiskConfig()

    # Create a new config with relaxed rate limits for backtest speed
    return RiskConfig(
        max_position_per_market=config.max_position_per_market,
        max_portfolio_notional=config.max_portfolio_notional,
        max_daily_drawdown_pct=config.max_daily_drawdown_pct,
        max_order_size=config.max_order_size,
        rate_limit_sustained=1_000_000,
        rate_limit_burst=1_000_000,
        dedup_window_ms=0,
    )
