"""AutoForecaster with automatic model selection."""

from typing import Optional, Union, Tuple, Dict, Any, List

import numpy as np
import pandas as pd

from ad_inventory_forecast.core.base import BaseForecaster
from ad_inventory_forecast.core.advisor import ModelAdvisor, ModelAdvice
from ad_inventory_forecast.backtesting.metrics import calculate_smape


class AutoForecaster(BaseForecaster):
    """
    Automatically selects and uses the best forecasting model.

    Analyzes data characteristics and selects an appropriate model
    without requiring user intervention. Handles:
    - Frequency detection (daily, weekly, monthly)
    - Data sufficiency checks
    - Model configuration based on data length
    - Graceful fallback when preferred models fail

    Parameters
    ----------
    strategy : str, default 'fast'
        Model selection strategy:
        - 'fast': Use heuristics based on data analysis, no cross-validation
        - 'balanced': Try top 2-3 models with holdout validation
        - 'thorough': Full walk-forward validation (uses AutoTuner)
    horizon : int, default 30
        Expected forecast horizon (used for model selection).
    verbose : bool, default False
        Whether to print selection process details.

    Attributes
    ----------
    advisor_ : ModelAdvisor
        The advisor used for data analysis.
    advice_ : ModelAdvice
        Complete advice from analysis (available after fit).
    selected_model_ : BaseForecaster
        The selected and fitted model (available after fit).

    Examples
    --------
    Simple usage - automatically selects and fits best model:

    >>> model = AutoForecaster()
    >>> model.fit(y)
    >>> forecast = model.predict(horizon=30)

    See what was selected:

    >>> print(model.advice_.selected_model)
    >>> print(model.advice_.selection_reasoning)

    With verbose output:

    >>> model = AutoForecaster(verbose=True)
    >>> model.fit(y)  # Prints analysis and selection details

    Using balanced strategy with holdout validation:

    >>> model = AutoForecaster(strategy='balanced')
    >>> model.fit(y)
    >>> print(f"Selected: {model.advice_.selected_model}")

    Notes
    -----
    The 'fast' strategy uses heuristic rules based on data characteristics
    and is recommended for most use cases. The 'balanced' strategy provides
    better accuracy guarantees by comparing models on held-out data. The
    'thorough' strategy uses full walk-forward cross-validation and is
    slower but most reliable for model selection.
    """

    def __init__(
        self,
        strategy: str = "fast",
        horizon: int = 30,
        verbose: bool = False,
    ):
        """Initialize the AutoForecaster."""
        super().__init__()

        if strategy not in ("fast", "balanced", "thorough"):
            raise ValueError(
                f"strategy must be 'fast', 'balanced', or 'thorough', got '{strategy}'"
            )

        self.strategy = strategy
        self.horizon = horizon
        self.verbose = verbose

        # Fitted attributes
        self.advisor_ = None
        self.advice_ = None
        self.selected_model_ = None
        self._selection_scores = None

    def fit(
        self,
        y: Union[pd.Series, np.ndarray],
        X: Optional[Union[pd.DataFrame, np.ndarray]] = None,
    ) -> "AutoForecaster":
        """
        Analyze data, select best model, and fit.

        Parameters
        ----------
        y : pd.Series or np.ndarray
            Target time series. If pd.Series, should have DatetimeIndex.
        X : pd.DataFrame or np.ndarray, optional
            Exogenous features (passed to underlying model if supported).

        Returns
        -------
        self : AutoForecaster
            Fitted forecaster instance.
        """
        # Validate input
        y = self._validate_y(y)
        self.training_series_ = y.copy()
        self.n_obs_ = len(y)
        self.freq_ = self._infer_frequency(y)

        # Create advisor and analyze data
        self.advisor_ = ModelAdvisor(verbose=self.verbose)
        self.advice_ = self.advisor_.analyze(y)

        # Select and fit model based on strategy
        if self.strategy == "fast":
            self._select_fast(y, X)
        elif self.strategy == "balanced":
            self._select_balanced(y, X)
        else:  # thorough
            self._select_thorough(y, X)

        self.is_fitted_ = True
        return self

    def _select_fast(
        self,
        y: pd.Series,
        X: Optional[Union[pd.DataFrame, np.ndarray]] = None,
    ) -> None:
        """Fast selection using advisor recommendations only."""
        model_name = self.advice_.selected_model
        model_params = self.advice_.selected_params

        if self.verbose:
            print(f"\nFitting {model_name}...")

        # Get model class from recommendations
        model_class = None
        for rec in self.advice_.recommendations:
            if rec.model_name == model_name:
                model_class = rec.model_class
                break

        if model_class is None:
            # Fallback
            from ad_inventory_forecast.models.decomposition import InventoryForecaster
            model_class = InventoryForecaster
            model_params = {"model": "auto"}

        # Instantiate and fit
        try:
            self.selected_model_ = model_class(**model_params)
            self.selected_model_.fit(y, X)
        except Exception as e:
            if self.verbose:
                print(f"  Failed: {e}")
                print("  Falling back to InventoryForecaster...")

            # Fallback
            from ad_inventory_forecast.models.decomposition import InventoryForecaster
            self.selected_model_ = InventoryForecaster(model="auto")
            self.selected_model_.fit(y, X)

            # Update advice to reflect actual selection
            self.advice_ = ModelAdvice(
                data_analysis=self.advice_.data_analysis,
                recommendations=self.advice_.recommendations,
                selected_model="InventoryForecaster",
                selected_params={"model": "auto"},
                selection_reasoning=f"Fallback after {model_name} failed: {str(e)}",
            )

    def _select_balanced(
        self,
        y: pd.Series,
        X: Optional[Union[pd.DataFrame, np.ndarray]] = None,
    ) -> None:
        """Balanced selection using holdout validation."""
        # Get candidate models (recommended and possible)
        candidates = []
        for rec in self.advice_.recommendations:
            if rec.suitability in ("recommended", "possible") and rec.model_class is not None:
                candidates.append(rec)

        if not candidates:
            # Fallback to fast selection
            self._select_fast(y, X)
            return

        # Limit to top 3 candidates
        candidates = candidates[:3]

        # Split data: 80% train, 20% validation
        n = len(y)
        train_size = int(n * 0.8)
        y_train = y.iloc[:train_size]
        y_val = y.iloc[train_size:]
        val_horizon = len(y_val)

        if self.verbose:
            print(f"\nBalanced selection with holdout ({train_size} train, {val_horizon} val)...")

        best_score = np.inf
        best_model = None
        best_rec = None
        scores = {}

        for rec in candidates:
            if self.verbose:
                print(f"  Trying {rec.model_name}...")

            try:
                model = rec.model_class(**rec.recommended_params)
                model.fit(y_train)

                # Predict on validation set
                forecast_result = model.predict(horizon=val_horizon, return_df=True)
                if isinstance(forecast_result, pd.DataFrame):
                    predictions = forecast_result["predicted_impressions"].values
                else:
                    predictions = forecast_result.values

                # Calculate SMAPE
                score = calculate_smape(y_val.values, predictions)
                scores[rec.model_name] = score

                if self.verbose:
                    print(f"    SMAPE: {score:.2f}%")

                if score < best_score:
                    best_score = score
                    best_rec = rec
                    best_model = model

            except Exception as e:
                if self.verbose:
                    print(f"    Failed: {e}")
                scores[rec.model_name] = np.inf

        self._selection_scores = scores

        if best_model is None:
            # All failed, use fallback
            if self.verbose:
                print("  All candidates failed, using fallback...")
            self._select_fast(y, X)
            return

        # Refit best model on full data
        if self.verbose:
            print(f"\nRefitting {best_rec.model_name} on full data...")

        self.selected_model_ = best_rec.model_class(**best_rec.recommended_params)
        self.selected_model_.fit(y, X)

        # Update advice
        self.advice_ = ModelAdvice(
            data_analysis=self.advice_.data_analysis,
            recommendations=self.advice_.recommendations,
            selected_model=best_rec.model_name,
            selected_params=best_rec.recommended_params,
            selection_reasoning=f"Selected via holdout validation (SMAPE: {best_score:.2f}%)",
        )

    def _select_thorough(
        self,
        y: pd.Series,
        X: Optional[Union[pd.DataFrame, np.ndarray]] = None,
    ) -> None:
        """Thorough selection using AutoTuner with walk-forward validation."""
        from ad_inventory_forecast.tuning.auto_tuner import AutoTuner

        if self.verbose:
            print("\nThorough selection with walk-forward validation...")

        # Build candidate list from recommendations
        candidates = []
        for rec in self.advice_.recommendations:
            if rec.suitability in ("recommended", "possible") and rec.model_class is not None:
                try:
                    model = rec.model_class(**rec.recommended_params)
                    candidates.append(model)
                except Exception:
                    continue

        if not candidates:
            # Fallback
            self._select_fast(y, X)
            return

        try:
            tuner = AutoTuner(
                candidates=candidates,
                metric="smape",
                n_folds=3,
                horizon=min(self.horizon, len(y) // 4),
                verbose=self.verbose,
            )

            result = tuner.select_best(y, X)

            self.selected_model_ = result.best_model
            self._selection_scores = {r["name"]: r["score"] for r in result.all_results}

            # Update advice
            self.advice_ = ModelAdvice(
                data_analysis=self.advice_.data_analysis,
                recommendations=self.advice_.recommendations,
                selected_model=result.best_model_name,
                selected_params=result.best_params,
                selection_reasoning=f"Selected via walk-forward CV (SMAPE: {result.best_score:.2f}%)",
            )

        except Exception as e:
            if self.verbose:
                print(f"  AutoTuner failed: {e}")
                print("  Falling back to balanced selection...")

            self._select_balanced(y, X)

    def predict(
        self,
        horizon: int,
        return_conf_int: bool = False,
        alpha: float = 0.05,
        return_df: bool = True,
    ) -> Union[pd.Series, pd.DataFrame, Tuple[pd.Series, pd.DataFrame]]:
        """
        Generate forecasts using the selected model.

        Parameters
        ----------
        horizon : int
            Number of periods to forecast.
        return_conf_int : bool, default False
            Whether to return confidence intervals (legacy mode).
        alpha : float, default 0.05
            Significance level for confidence intervals.
        return_df : bool, default True
            If True (default), returns DataFrame with 'predicted_impressions',
            'lower_bound', 'upper_bound' columns.

        Returns
        -------
        forecast_df : pd.DataFrame
            If return_df=True: DataFrame with prediction columns.
        forecasts : pd.Series
            If return_df=False: Point forecasts with DatetimeIndex.
        conf_int : pd.DataFrame, optional
            Confidence intervals. Only returned if return_conf_int=True.
        """
        self._check_is_fitted()

        return self.selected_model_.predict(
            horizon=horizon,
            return_conf_int=return_conf_int,
            alpha=alpha,
            return_df=return_df,
        )

    def get_advice(self) -> ModelAdvice:
        """
        Return the analysis and recommendations.

        Returns
        -------
        ModelAdvice
            Complete advice including data analysis, model recommendations,
            and selection reasoning.

        Raises
        ------
        ValueError
            If the model has not been fitted.
        """
        self._check_is_fitted()
        return self.advice_

    def get_selection_scores(self) -> Optional[Dict[str, float]]:
        """
        Return validation scores for each candidate model.

        Only available when using 'balanced' or 'thorough' strategy.

        Returns
        -------
        scores : Dict[str, float] or None
            Dictionary mapping model names to SMAPE scores.
            None if 'fast' strategy was used.
        """
        self._check_is_fitted()
        return self._selection_scores

    def get_selected_model(self) -> BaseForecaster:
        """
        Return the selected and fitted model.

        Returns
        -------
        model : BaseForecaster
            The underlying fitted forecaster.

        Raises
        ------
        ValueError
            If the model has not been fitted.
        """
        self._check_is_fitted()
        return self.selected_model_

    def get_params(self, deep: bool = True) -> Dict[str, Any]:
        """Get parameters for this estimator."""
        return {
            "strategy": self.strategy,
            "horizon": self.horizon,
            "verbose": self.verbose,
        }

    def set_params(self, **params) -> "AutoForecaster":
        """Set the parameters of this estimator."""
        for key, value in params.items():
            if hasattr(self, key):
                setattr(self, key, value)
            else:
                raise ValueError(f"Invalid parameter {key}")
        return self

    def __repr__(self) -> str:
        """Return string representation."""
        if self.is_fitted_:
            return (
                f"AutoForecaster(strategy='{self.strategy}', "
                f"selected_model='{self.advice_.selected_model}')"
            )
        return f"AutoForecaster(strategy='{self.strategy}')"
