#!/usr/bin/env python3
"""Bayesian hyperparameter optimizer using optuna TPE sampler.

Wraps the existing backtest_strategy from offline_train with an optuna
study for efficient parameter search.  Supports walk-forward cross-
validation to guard against overfitting.

Usage::

    from bayesian_optimizer import BayesianOptimizer, OptunaStudyParams

    opt = BayesianOptimizer(df, OptunaStudyParams(n_trials=200))
    best_params, study = opt.optimize()
    print(best_params, study.best_value)
"""

from __future__ import annotations

import json
import sys
import time as _time
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any

import numpy as np

_OPTUNA_AVAILABLE = False
try:
    import optuna

    _OPTUNA_AVAILABLE = True
except ImportError:
    pass

SRC_DIR = Path(__file__).parent
PROJECT = SRC_DIR.parent
sys.path.insert(0, str(PROJECT))
import config_manager


@dataclass
class OptunaStudyParams:
    """Configuration for a Bayesian optimization study."""

    n_trials: int = 200
    n_warmup_steps: int = 30
    n_folds: int = 3
    fold_ratio: float = 0.7
    min_bars_per_fold: int = 200
    sampler_seed: int = 42
    study_name: str = "gold_autotrade_bayesian"
    storage: str = ""  # sqlite:///... or empty for in-memory
    direction: str = "maximize"

    # parameter space bounds — expanded from 4 to 15 params covering entry/exit/risk
    param_ranges: dict[str, Any] = field(default_factory=lambda: {
        # === entry parameters ===
        "ema_short": {"type": "int", "low": 4, "high": 24, "step": 1},
        "ema_long": {"type": "int", "low": 12, "high": 72, "step": 1},
        "adx_threshold": {"type": "int", "low": 15, "high": 40, "step": 1},
        "rsi_oversold": {"type": "int", "low": 20, "high": 40, "step": 1},
        "rsi_overbought": {"type": "int", "low": 60, "high": 85, "step": 1},
        # === stop-loss / take-profit ===
        "sl_atr": {"type": "float", "low": 0.5, "high": 3.0, "step": 0.1},
        "tp_atr": {"type": "float", "low": 1.0, "high": 6.0, "step": 0.1},
        "trailing_stop_atr": {"type": "float", "low": 0.3, "high": 2.0, "step": 0.1},
        "breakeven_atr": {"type": "float", "low": 0.3, "high": 1.5, "step": 0.1},
        # === position sizing ===
        "risk_per_trade_pct": {"type": "float", "low": 0.2, "high": 2.0, "step": 0.1},
        "max_position_size": {"type": "float", "low": 0.02, "high": 0.20, "step": 0.01},
        # === risk control ===
        "max_consecutive_losses": {"type": "int", "low": 2, "high": 6, "step": 1},
        "cooldown_base_sec": {"type": "int", "low": 60, "high": 600, "step": 30},
        # === exit strategy ===
        "partial_close_atr": {"type": "float", "low": 0.5, "high": 2.5, "step": 0.1},
        "partial_close_pct": {"type": "float", "low": 0.30, "high": 0.70, "step": 0.05},
    })


class BayesianOptimizer:
    """Bayesian hyperparameter optimization via optuna TPE.

    Wraps the offline_train backtest pipeline so every trial runs a
    full walk-forward cross-validation and returns the average score.
    """

    def __init__(
        self,
        df,
        params: OptunaStudyParams | None = None,
    ):
        if not _OPTUNA_AVAILABLE:
            raise ImportError(
                "optuna is required for Bayesian optimization. "
                "Install it with: pip install optuna"
            )
        self._df = df
        self._params = params or OptunaStudyParams()

    # -- public API -------------------------------------------------------

    def optimize(self) -> tuple[dict[str, Any], Any]:
        """Run the study and return (best_params_dict, optuna_study)."""
        sampler = optuna.samplers.TPESampler(
            seed=self._params.sampler_seed,
            n_startup_trials=self._params.n_warmup_steps,
        )

        study_kwargs: dict[str, Any] = {
            "study_name": self._params.study_name,
            "direction": self._params.direction,
            "sampler": sampler,
        }
        if self._params.storage:
            study_kwargs["storage"] = self._params.storage
            study_kwargs["load_if_exists"] = True

        study = optuna.create_study(**study_kwargs)

        def objective(trial: optuna.Trial) -> float:
            trial_params = {}
            for name, spec in self._params.param_ranges.items():
                if spec["type"] == "int":
                    trial_params[name] = trial.suggest_int(
                        name, spec["low"], spec["high"], step=spec.get("step", 1)
                    )
                elif spec["type"] == "float":
                    trial_params[name] = trial.suggest_float(
                        name, spec["low"], spec["high"], step=spec.get("step", 0.1)
                    )
            return self._cross_validate(trial_params)

        study.optimize(objective, n_trials=self._params.n_trials)

        best_params = dict(study.best_params)
        _persist_study_result(study, best_params)
        return best_params, study

    # -- cross-validation -------------------------------------------------

    def _cross_validate(self, params: dict[str, Any]) -> float:
        from offline_train import compute_indicators, backtest_strategy

        n = len(self._df)
        fold_size = n // self._params.n_folds
        if fold_size < self._params.min_bars_per_fold:
            df_eval = compute_indicators(
                self._df,
                ema_short=int(params.get("ema_short", 9)),
                ema_long=int(params.get("ema_long", 21)),
            ).dropna()
            result = backtest_strategy(df_eval, params)
            return float(result.get("score", -9999))

        scores: list[float] = []
        for fold in range(self._params.n_folds):
            train_end = int((fold + 1) * fold_size)
            train_df = self._df.il[:train_end]
            if len(train_df) < self._params.min_bars_per_fold:
                continue
            df_eval = compute_indicators(
                train_df,
                ema_short=int(params.get("ema_short", 9)),
                ema_long=int(params.get("ema_long", 21)),
            ).dropna()
            result = backtest_strategy(df_eval, params)
            scores.append(float(result.get("score", -9999)))

        if not scores:
            return -9999.0
        avg = float(np.mean(scores))
        std = float(np.std(scores)) if len(scores) > 1 else 0.0
        return round(avg - 0.5 * std, 2)


# -- persistence ---------------------------------------------------------


def _persist_study_result(study, best_params: dict[str, Any]) -> None:
    out_dir = config_manager.get_data_dir() / "bayesian"
    out_dir.mkdir(parents=True, exist_ok=True)
    result = {
        "timestamp": datetime.now().isoformat(),
        "study_name": study.study_name,
        "best_value": study.best_value,
        "best_params": best_params,
        "n_trials": len(study.trials),
    }
    (out_dir / "bayesian_best_params.json").write_text(
        json.dumps(result, indent=2, default=str), encoding="utf-8"
    )
    # bayesian_study_result.json removed — duplicate of bayesian_best_params.json
