"""Base classes for forecasting models."""

from abc import ABC, abstractmethod
from typing import Optional, Union, Dict, Any, Tuple

import numpy as np
import pandas as pd
from sklearn.base import BaseEstimator, RegressorMixin


class BaseForecaster(ABC, BaseEstimator, RegressorMixin):
    """
    Abstract base class for all forecasters.

    Implements scikit-learn's estimator interface for compatibility with
    MLOps pipelines, cross-validation, and model selection utilities.

    All forecasters must implement:
    - fit(y, X=None): Fit the model to training data
    - predict(horizon): Generate forecasts for the specified horizon

    Attributes
    ----------
    is_fitted_ : bool
        Whether the model has been fitted.
    n_obs_ : int
        Number of observations in the training data.
    freq_ : str
        Inferred frequency of the time series ('D', 'W', 'M').
    training_series_ : pd.Series
        The training time series (stored for metrics calculation).
    """

    def __init__(self):
        """Initialize the base forecaster."""
        self.is_fitted_ = False
        self.n_obs_ = None
        self.freq_ = None
        self.training_series_ = None

    @abstractmethod
    def fit(
        self,
        y: Union[pd.Series, np.ndarray],
        X: Optional[Union[pd.DataFrame, np.ndarray]] = None,
    ) -> "BaseForecaster":
        """
        Fit the forecaster to training data.

        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 (not used by most forecasters).

        Returns
        -------
        self : BaseForecaster
            Fitted forecaster instance.
        """
        pass

    @abstractmethod
    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 for the specified horizon.

        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 (e.g., 0.05 for 95% CI).
        return_df : bool, default True
            If True (default), returns DataFrame with 'predicted_impressions',
            'lower_bound', 'upper_bound' columns (SDK-compliant format).
            If False, returns Series (legacy format).

        Returns
        -------
        forecast_df : pd.DataFrame
            If return_df=True (default): DataFrame with columns:
            - 'predicted_impressions': Point forecasts
            - 'lower_bound': Lower confidence bound
            - 'upper_bound': Upper confidence bound
        forecasts : pd.Series
            If return_df=False: Point forecasts with DatetimeIndex.
        conf_int : pd.DataFrame, optional
            Confidence intervals with 'lower' and 'upper' columns.
            Only returned if return_conf_int=True.
        """
        pass

    def fit_predict(
        self,
        y: Union[pd.Series, np.ndarray],
        horizon: int,
        X: Optional[Union[pd.DataFrame, np.ndarray]] = None,
        return_conf_int: bool = False,
        alpha: float = 0.05,
        return_df: bool = True,
    ) -> Union[pd.Series, pd.DataFrame, Tuple[pd.Series, pd.DataFrame]]:
        """
        Fit the model and generate forecasts in one step.

        Parameters
        ----------
        y : pd.Series or np.ndarray
            Target time series.
        horizon : int
            Number of periods to forecast.
        X : pd.DataFrame or np.ndarray, optional
            Exogenous features.
        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 (SDK-compliant format).

        Returns
        -------
        forecast_df : pd.DataFrame
            If return_df=True (default): 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.fit(y, X)
        return self.predict(
            horizon,
            return_conf_int=return_conf_int,
            alpha=alpha,
            return_df=return_df,
        )

    def score(
        self,
        y_true: Union[pd.Series, np.ndarray],
        y_pred: Optional[Union[pd.Series, np.ndarray]] = None,
    ) -> float:
        """
        Calculate the negative MASE score (for sklearn compatibility).

        sklearn maximizes scores, but lower MASE is better, so we return negative.

        Parameters
        ----------
        y_true : pd.Series or np.ndarray
            Actual values.
        y_pred : pd.Series or np.ndarray, optional
            Predicted values. If None, will predict for len(y_true) periods.

        Returns
        -------
        score : float
            Negative MASE score (higher is better for sklearn).
        """
        if y_pred is None:
            y_pred = self.predict(len(y_true))

        # Import here to avoid circular imports
        from ad_inventory_forecast.backtesting.metrics import calculate_mase

        if self.training_series_ is None:
            raise ValueError("Model must be fitted before scoring.")

        mase = calculate_mase(
            actual=np.asarray(y_true),
            predicted=np.asarray(y_pred),
            training_series=np.asarray(self.training_series_),
            period=self._get_seasonal_period(),
        )
        return -mase  # Negative because sklearn maximizes

    def _validate_y(self, y: Union[pd.Series, np.ndarray]) -> pd.Series:
        """
        Validate and convert target series to pd.Series with DatetimeIndex.

        Parameters
        ----------
        y : pd.Series or np.ndarray
            Input target series.

        Returns
        -------
        y : pd.Series
            Validated series with DatetimeIndex.

        Raises
        ------
        ValueError
            If input is invalid or has insufficient data.
        """
        if isinstance(y, np.ndarray):
            # Create a default DatetimeIndex starting from today
            dates = pd.date_range(
                start=pd.Timestamp.today().normalize(),
                periods=len(y),
                freq="D",
            )
            y = pd.Series(y, index=dates, name="impressions")
        elif isinstance(y, pd.Series):
            if not isinstance(y.index, pd.DatetimeIndex):
                raise ValueError("Series must have DatetimeIndex.")
            y = y.copy()
        else:
            raise ValueError(f"y must be pd.Series or np.ndarray, got {type(y)}")

        # Check for sufficient data
        if len(y) < 2:
            raise ValueError("Need at least 2 observations.")

        # Check for non-negative values
        if (y < 0).any():
            raise ValueError("Target series contains negative values.")

        return y

    def _infer_frequency(self, y: pd.Series) -> str:
        """
        Infer the frequency of the time series.

        Parameters
        ----------
        y : pd.Series
            Time series with DatetimeIndex.

        Returns
        -------
        freq : str
            Inferred frequency ('D' for daily, 'W' for weekly, 'M' for monthly).
        """
        if y.index.freq is not None:
            freq_str = y.index.freq.name
            if freq_str in ("D", "B"):
                return "D"
            elif freq_str in ("W", "W-SUN", "W-MON"):
                return "W"
            elif freq_str in ("M", "MS", "ME"):
                return "M"

        # Infer from median difference between observations
        diffs = y.index.to_series().diff().dropna()
        if len(diffs) == 0:
            return "D"  # Default to daily

        median_diff = diffs.median()

        if median_diff <= pd.Timedelta(days=2):
            return "D"
        elif median_diff <= pd.Timedelta(days=10):
            return "W"
        else:
            return "M"

    def _get_seasonal_period(self) -> int:
        """
        Get the primary seasonal period based on frequency.

        Returns
        -------
        period : int
            Seasonal period (7 for daily, 52 for weekly, 12 for monthly).
        """
        if self.freq_ == "D":
            return 7  # Weekly seasonality
        elif self.freq_ == "W":
            return 52  # Annual seasonality
        elif self.freq_ == "M":
            return 12  # Annual seasonality
        else:
            return 7  # Default

    def _generate_forecast_index(
        self,
        last_date: pd.Timestamp,
        horizon: int,
    ) -> pd.DatetimeIndex:
        """
        Generate DatetimeIndex for forecast periods.

        Parameters
        ----------
        last_date : pd.Timestamp
            Last date in the training series.
        horizon : int
            Number of periods to forecast.

        Returns
        -------
        index : pd.DatetimeIndex
            Index for forecast periods.
        """
        freq_map = {"D": "D", "W": "W", "M": "MS"}
        freq = freq_map.get(self.freq_, "D")

        return pd.date_range(
            start=last_date + pd.Timedelta(days=1) if freq == "D" else last_date,
            periods=horizon + 1,
            freq=freq,
        )[1:]  # Exclude the start date

    def _check_is_fitted(self) -> None:
        """
        Check if the model has been fitted.

        Raises
        ------
        ValueError
            If the model has not been fitted.
        """
        if not self.is_fitted_:
            raise ValueError(
                f"{self.__class__.__name__} has not been fitted. "
                "Call fit() before predict()."
            )

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

        Parameters
        ----------
        deep : bool, default True
            If True, will return the parameters for this estimator and
            contained subobjects that are estimators.

        Returns
        -------
        params : dict
            Parameter names mapped to their values.
        """
        # Get all init parameters from __init__ signature
        import inspect

        params = {}
        init_signature = inspect.signature(self.__init__)

        for name, param in init_signature.parameters.items():
            if name == "self":
                continue
            if hasattr(self, name):
                params[name] = getattr(self, name)
            elif param.default is not inspect.Parameter.empty:
                params[name] = param.default

        return params

    def set_params(self, **params) -> "BaseForecaster":
        """
        Set the parameters of this estimator.

        Parameters
        ----------
        **params : dict
            Estimator parameters.

        Returns
        -------
        self : BaseForecaster
            Estimator instance.
        """
        for key, value in params.items():
            if hasattr(self, key):
                setattr(self, key, value)
            else:
                raise ValueError(f"Invalid parameter {key} for {self.__class__.__name__}")
        return self

    def __repr__(self) -> str:
        """Return string representation of the forecaster."""
        params = self.get_params()
        params_str = ", ".join(f"{k}={v!r}" for k, v in params.items())
        return f"{self.__class__.__name__}({params_str})"
