"""Parameter grid search engine for intraday backtesting.

Data is fetched once; simulations run repeatedly with different params.
288 combinations × ~200 days ≈ 5 minutes total simulation time.
"""
from __future__ import annotations

import itertools
from typing import Any

import yaml

from libs.intraday.domain import (
    IntradayConfig,
    ORBStrategyParams,
    StrategyParams,
    SweepResult,
)
from libs.intraday.metrics import compute_metrics
from libs.intraday.simulator import run_simulation


class SweepConfig:
    """Parsed sweep configuration."""

    def __init__(
        self,
        base_config: IntradayConfig,
        sweep_params: dict[str, list[Any]],
        objective: dict[str, Any] | None = None,
    ) -> None:
        self.base_config = base_config
        self.sweep_params = sweep_params
        self.objective = objective or {}

    @property
    def total_combinations(self) -> int:
        total = 1
        for vals in self.sweep_params.values():
            total *= len(vals)
        return total


def _normalize_sweep_value(
    field_name: str,
    value: Any,
    model_fields: dict[str, Any],
) -> Any:
    """Normalize YAML sweep values without breaking literal string enums like 'none'."""
    if value is None or value in ("null", "None"):
        return None
    if value != "none":
        return value
    field = model_fields.get(field_name)
    if field is not None and getattr(field, "default", None) == "none":
        return "none"
    return None


def load_sweep_config(sweep_path: str, base_config: IntradayConfig) -> SweepConfig:
    """Load a sweep YAML and merge with the base config."""
    with open(sweep_path) as f:
        raw = yaml.safe_load(f)

    model_fields = (
        ORBStrategyParams.model_fields
        if base_config.strategy_mode == "orb"
        else StrategyParams.model_fields
    )
    sweep_params: dict[str, list[Any]] = {}
    for key, vals in raw.get("sweep", {}).items():
        if not isinstance(vals, list):
            vals = [vals]
        # Normalize None strings and null values
        normalized = [_normalize_sweep_value(key, v, model_fields) for v in vals]
        sweep_params[key] = normalized

    objective = raw.get("objective", {}) or {}
    return SweepConfig(base_config=base_config, sweep_params=sweep_params, objective=objective)


def _trade_day_objective_score(metrics: Any, objective: dict[str, Any]) -> float:
    """Score sweep rows with an explicit bonus for safe capital usage.

    The default remains Sharpe sorting unless a sweep YAML opts into
    objective.name: trade_day_adjusted.
    """
    sharpe = float(metrics.sharpe_ratio or -999.0)
    total_return = float(metrics.total_return_pct or -999.0)
    trade_days = float(metrics.days_with_trades or 0)
    target_trade_days = float(objective.get("target_days_with_trades") or 80)
    trade_day_bonus = float(objective.get("trade_day_bonus") or 0.0)
    return_weight = float(objective.get("return_weight") or 0.10)
    min_return = objective.get("min_total_return_pct")
    max_drawdown_floor = objective.get("max_drawdown_floor")
    min_profit_factor = objective.get("min_profit_factor")

    score = sharpe + (total_return * return_weight)
    if target_trade_days > 0:
        score += min(trade_days / target_trade_days, 1.0) * trade_day_bonus

    if min_return is not None and total_return < float(min_return):
        score -= 100.0
    max_drawdown = metrics.max_drawdown_pct
    if max_drawdown_floor is not None and max_drawdown is not None:
        if float(max_drawdown) < float(max_drawdown_floor):
            score -= 100.0
    profit_factor = metrics.profit_factor
    if min_profit_factor is not None and profit_factor is not None:
        if float(profit_factor) < float(min_profit_factor):
            score -= 100.0
    return score


def _sweep_objective_score(metrics: Any, objective: dict[str, Any]) -> float:
    if objective.get("name") == "trade_day_adjusted":
        return _trade_day_objective_score(metrics, objective)
    return float(metrics.sharpe_ratio or -999.0) + float(metrics.total_return_pct or -999.0) * 0.001


def generate_combinations(sweep: SweepConfig) -> list[dict[str, Any]]:
    """Generate Cartesian product of all sweep parameters."""
    keys = sorted(sweep.sweep_params.keys())
    values = [sweep.sweep_params[k] for k in keys]
    combos = list(itertools.product(*values))
    return [dict(zip(keys, combo)) for combo in combos]


def apply_overrides(base_config: IntradayConfig, overrides: dict[str, Any]) -> IntradayConfig:
    """Apply parameter overrides to base config, returning a new config.

    Branches on strategy_mode: momentum overrides go to StrategyParams,
    ORB overrides go to ORBStrategyParams.
    """
    if base_config.strategy_mode == "orb":
        orb = base_config.orb_strategy or ORBStrategyParams()
        orb_dict = orb.model_dump()
        orb_fields = set(ORBStrategyParams.model_fields.keys())
        for key, val in overrides.items():
            if key in orb_fields:
                orb_dict[key] = val
        new_orb = ORBStrategyParams(**orb_dict)
        return base_config.model_copy(update={"orb_strategy": new_orb})

    # Momentum mode (default)
    strategy_dict = base_config.strategy.model_dump()
    strategy_fields = set(StrategyParams.model_fields.keys())
    for key, val in overrides.items():
        if key in strategy_fields:
            strategy_dict[key] = val
    new_strategy = StrategyParams(**strategy_dict)
    return base_config.model_copy(update={"strategy": new_strategy})


def _filter_intraday_by_candidate_map(
    all_intraday: dict[str, dict[str, list[dict]]],
    candidate_map: dict[str, list[str]] | None,
) -> dict[str, dict[str, list[dict]]]:
    """Restrict preloaded intraday data to the combo-specific candidate set."""
    if not candidate_map:
        return all_intraday
    filtered: dict[str, dict[str, list[dict]]] = {}
    for day, tickers in candidate_map.items():
        day_intraday = all_intraday.get(day, {})
        if not day_intraday:
            continue
        selected = {
            ticker: day_intraday[ticker]
            for ticker in tickers
            if ticker in day_intraday
        }
        if selected:
            filtered[day] = selected
    return filtered


def run_sweep(
    sweep: SweepConfig,
    all_intraday: dict[str, dict[str, list[dict]]],
    trading_days: list[str],
    progress_callback: Any = None,
    enrichment: dict | None = None,
    momentum_enrichment: dict | None = None,
    vix_by_day: dict[str, float] | None = None,
    ticker_sectors: dict[str, str] | None = None,
    sector_proxy_intraday_by_day: dict[str, dict[str, list[dict]]] | None = None,
    overlay_tickers_per_day: dict[str, set[str]] | None = None,
    orb_context_resolver: Any = None,
) -> list[SweepResult]:
    """Run simulation for each parameter combination.

    Data is pre-fetched and shared across all runs.
    Only the simulation (pure CPU computation) varies per combination.

    Args:
        sweep: SweepConfig with base config and param grid.
        all_intraday: Pre-loaded {date: {ticker: [bars]}} data.
        trading_days: List of dates.
        progress_callback: Optional callable(completed, total) for progress.
        enrichment: Pre-computed daily enrichment (required for ORB strategy).

    Returns:
        List of SweepResult sorted by Sharpe ratio descending.
    """
    combos = generate_combinations(sweep)
    results: list[SweepResult] = []
    is_orb = sweep.base_config.strategy_mode == "orb"

    for i, overrides in enumerate(combos):
        config = apply_overrides(sweep.base_config, overrides)

        if is_orb:
            combo_enrichment = enrichment or {}
            combo_vix = vix_by_day
            combo_overlay_tickers = overlay_tickers_per_day
            combo_candidate_map = None
            if orb_context_resolver is not None:
                resolved = orb_context_resolver(config)
                if len(resolved) == 4:
                    combo_enrichment, combo_vix, combo_overlay_tickers, combo_candidate_map = resolved
                else:
                    combo_enrichment, combo_vix, combo_overlay_tickers = resolved
            from libs.intraday.orb_simulator import run_orb_simulation
            combo_intraday = _filter_intraday_by_candidate_map(all_intraday, combo_candidate_map)
            day_results = run_orb_simulation(
                combo_intraday, trading_days, config.orb_strategy, combo_enrichment,
                ticker_sectors=ticker_sectors,
                vix_by_day=combo_vix,
                overlay_tickers_per_day=combo_overlay_tickers,
            )
        else:
            day_results = run_simulation(
                all_intraday,
                trading_days,
                config.strategy,
                daily_enrichment=momentum_enrichment,
                vix_by_day=vix_by_day,
                ticker_sectors=ticker_sectors,
                sector_proxy_intraday_by_day=sector_proxy_intraday_by_day,
            )

        metrics = compute_metrics(day_results, config, run_id=f"sw{i:04d}")
        objective_score = _sweep_objective_score(metrics, sweep.objective)
        results.append(
            SweepResult(
                params=overrides,
                metrics=metrics,
                objective_score=round(objective_score, 6),
            )
        )

        if progress_callback:
            progress_callback(i + 1, len(combos))

    # Sort by configured objective. Default behavior is Sharpe-like unless the
    # sweep YAML opts into a trade-day-adjusted objective.
    results.sort(
        key=lambda r: (
            r.objective_score if r.objective_score is not None else float("-inf"),
            r.metrics.sharpe_ratio or float("-inf"),
            r.metrics.total_return_pct or -999,
        ),
        reverse=True,
    )
    return results
