import numpy as np
from pyoptim.datamanip import get_RNG


def Ridge_parameters(X: np.ndarray, y: np.ndarray, lam: float) -> np.ndarray:
    """Computes the Ridge regression parameters.
    Args:
        X: The input data matrix of shape (n_samples, n_features).
        y: The target vector of shape (n_samples,).
        lam: The regularization parameter (lambda).
    Returns:
        The Ridge regression parameters of shape (n_features,).
    """
    # Assumes X is scaled and has no intercept column
    return np.linalg.inv(X.T @ X + lam * np.eye(X.shape[1])) @ X.T @ y


def OLS_parameters(X: np.ndarray, y: np.ndarray) -> np.ndarray:
    """Computes the Ordinary Least Squares (OLS) regression parameters.
    Args:
        X: The input data matrix of shape (n_samples, n_features).
        y: The target vector of shape (n_samples,).
    Returns:
        The OLS regression parameters of shape (n_features,).
    """
    return Ridge_parameters(X, y, lam=0.0)  # OLS is Ridge with lambda=0


class GradientDescent:
    def __init__(
        self, *args, learning_rate: float = 0.1, num_iterations: int = 1000, **kwargs
    ):
        """Gradient Descent Class

        Args:
            learning_rate (float, optional): Learning rate used for step updates. Defaults to 0.1.
            num_iterations (int, optional): Number of iterations for gradient descent. Defaults to 1000.
        """
        self._cost_history = np.zeros(num_iterations)
        self.learning_rate = learning_rate
        self.num_iterations = num_iterations

    @property
    def cost_history(self) -> np.ndarray:
        """Returns the cost history of the optimization process.

        Returns:
            np.ndarray: Array of cost values for each iteration.
        """
        return self._cost_history

    def get_epochs(self) -> np.ndarray:
        """Returns an array of epoch numbers from 0 to num_iterations - 1.

        Returns:
            np.ndarray: Array of epoch numbers.
        """
        return np.arange(self.num_iterations)

    def fit(self, X: np.ndarray, y: np.ndarray) -> np.ndarray:
        """Returns the optimal solution for an optimization problem using the gradient descent

        Args:
            X (np.ndarray): X values
            y (np.ndarray): y values

        Returns:
            np.ndarray: Optimal parameters
        """
        self.X = X
        self.y = y
        self.theta = np.zeros(X.shape[1])
        self._precomp()
        for t in range(self.num_iterations):
            self._comp_step()
            self._cost_history[t] = self._compute_cost()
            self._update_theta()
        return self.theta

    def _precomp(self):
        pass

    def _comp_step(self):
        pass

    def _compute_cost(self):
        pass

    def _update_theta(self):
        pass


class OLSGradientDescent(GradientDescent):
    def _precomp(self):
        self.XTX = self.X.T @ self.X
        self.XTy = self.X.T @ self.y
        self.n = len(self.y)

    def _comp_step(self):
        self.err = self.X @ self.theta - self.y

    def _compute_cost(self) -> float:
        return 0.5 * np.mean(np.square(self.err))

    def _compute_grad(self) -> np.ndarray:
        return (self.XTX @ self.theta - self.XTy) / self.n

    def _update_theta(self):
        self.theta -= self.learning_rate * self._compute_grad()


class RidgeGradientDescent(OLSGradientDescent):
    def __init__(self, *args, lam: float = 0.1, **kwargs):
        super().__init__(*args, **kwargs)
        self.lam = lam

    def _compute_cost(self) -> float:
        return super()._compute_cost() + 0.5 * self.lam * np.sum(np.square(self.theta))

    def _compute_grad(self) -> np.ndarray:
        return super()._compute_grad() + self.lam * self.theta


class OLSMomentum(OLSGradientDescent):
    def __init__(self, *args, delta: float = 1.0, **kwargs):
        super().__init__(*args, **kwargs)
        self.delta = delta

    def _precomp(self):
        self.last_theta = np.zeros_like(self.theta)
        return super()._precomp()

    def _update_theta(self):
        v = self.delta * (
            self.theta - self.last_theta - self.learning_rate * self._compute_grad()
        )
        self.last_theta = self.theta.copy()
        self.theta += v


class RidgeMomentum(OLSMomentum, RidgeGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class OLSAdaGrad(OLSGradientDescent):
    def __init__(self, *args, learning_rate=0.1, num_iterations=1000, **kwargs):
        super().__init__(
            *args, learning_rate=learning_rate, num_iterations=num_iterations, **kwargs
        )

    def _precomp(self):
        self.quad_sum = np.zeros_like(self.theta)
        return super()._precomp()

    def _update_theta(self):
        grad = self._compute_grad()
        self.quad_sum += np.square(grad)
        self.theta -= (
            self.learning_rate * grad / (np.sqrt(self.quad_sum) + 1e-10)
        )  # to avoid division by zero


class RidgeAdaGrad(OLSAdaGrad, RidgeGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class OLSRMSProp(OLSGradientDescent):
    def __init__(
        self, *args, learning_rate=0.1, num_iterations=1000, gamma=0.9, **kwargs
    ):
        super().__init__(
            *args, learning_rate=learning_rate, num_iterations=num_iterations, **kwargs
        )
        self.gamma = gamma

    def _precomp(self):
        self.prev_v = np.zeros_like(self.theta)
        return super()._precomp()

    def _update_theta(self):
        grad = self._compute_grad()
        v = self.gamma * self.prev_v + (1 - self.gamma) * np.square(grad)
        self.theta -= (
            self.learning_rate * grad / (np.sqrt(v) + 1e-10)
        )  # to avoid division by zero
        self.prev_v = v


class RidgeRMSProp(OLSRMSProp, RidgeGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class OLSAdam(OLSGradientDescent):
    def __init__(
        self,
        *args,
        learning_rate=0.1,
        num_iterations=1000,
        beta1=0.9,
        beta2=0.999,
        **kwargs,
    ):
        super().__init__(
            *args, learning_rate=learning_rate, num_iterations=num_iterations, **kwargs
        )

        self.beta1 = beta1
        self.beta2 = beta2

    def _precomp(self):
        self.m = np.zeros_like(self.theta)
        self.v = np.zeros_like(self.theta)
        self.current_iteration = 0
        return super()._precomp()

    def _update_theta(self):
        self.current_iteration += 1
        grad = self._compute_grad()
        self.m = self.beta1 * self.m + (1 - self.beta1) * grad
        self.v = self.beta2 * self.v + (1 - self.beta2) * np.square(grad)

        m_hat = self.m / (1 - self.beta1 ** (self.current_iteration))
        v_hat = self.v / (1 - self.beta2 ** (self.current_iteration))

        self.theta -= (
            self.learning_rate * m_hat / (np.sqrt(v_hat) + 1e-10)
        )  # to avoid division by zero


class RidgeAdam(OLSAdam, RidgeGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class LASSOGradientDescent(OLSGradientDescent):
    def __init__(self, *args, lam: float = 0.1, **kwargs):
        super().__init__(*args, **kwargs)
        self.lam = lam

    def _compute_cost(self) -> float:
        return super()._compute_cost() + self.lam * np.sum(np.abs(self.theta))

    def _compute_grad(self) -> np.ndarray:
        return super()._compute_grad() + self.lam * np.sign(self.theta)


class LASSOMomentum(OLSMomentum, LASSOGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class LASSOAdaGrad(OLSAdaGrad, LASSOGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class LASSORMSProp(OLSRMSProp, LASSOGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class LASSOAdam(OLSAdam, LASSOGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class OLSStochasticGradientDescent(OLSGradientDescent):
    def __init__(
        self, *args, batch_size: int = 100, batches_per_epoch: int = 1, **kwargs
    ):
        # print(self.__class__.__name__) # If you see this: debugging yaaaay, the programmer that wrote this line is stupid...
        super().__init__(*args, **kwargs)
        self.batch_size = batch_size
        self.batches_per_epoch = batches_per_epoch
        self.RNG = get_RNG()

    def _precomp(self):
        self.N = len(self.y)
        self.indices = np.arange(self.N)
        self.n = self.batch_size
        self.RNG.shuffle(self.indices)
        self.X = self.X[self.indices]
        self.y = self.y[self.indices]

    def _comp_step(self):
        index = self.RNG.integers(0, self.N)
        batch_indices = slice(index, index + self.batch_size)
        if index + self.batch_size > self.N:
            batch_indices = slice(index, self.N)

        X_batch = self.X[batch_indices]
        y_batch = self.y[batch_indices]
        self.err = X_batch @ self.theta - y_batch
        self.XTX = X_batch.T @ X_batch
        self.XTy = X_batch.T @ y_batch

    def get_epochs(self):
        return np.arange(self.num_iterations // self.batches_per_epoch)

    @property
    def cost_history(self) -> np.ndarray:
        """Returns the cost history of the optimization process.

        Returns:
            np.ndarray: Array of cost values for each epoch.
        """
        return np.mean(self._cost_history.reshape(-1, self.batches_per_epoch), axis=1)


class RidgeStochasticGradientDescent(
    OLSStochasticGradientDescent, RidgeGradientDescent
):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class LASSOStochasticGradientDescent(
    OLSStochasticGradientDescent, LASSOGradientDescent
):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class OLSMomentumSGD(OLSMomentum, OLSStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class RidgeMomentumSGD(RidgeMomentum, RidgeStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class LASSOMomentumSGD(LASSOMomentum, LASSOStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class OLSAdaGradSGD(OLSAdaGrad, OLSStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class RidgeAdaGradSGD(RidgeAdaGrad, RidgeStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class LASSOAdaGradSGD(LASSOAdaGrad, LASSOStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class OLSRMSPropSGD(OLSRMSProp, OLSStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class RidgeRMSPropSGD(RidgeRMSProp, RidgeStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class LASSORMSPropSGD(LASSORMSProp, LASSOStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class OLSAdamSGD(OLSAdam, OLSStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class RidgeAdamSGD(RidgeAdam, RidgeStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


class LASSOAdamSGD(LASSOAdam, LASSOStochasticGradientDescent):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)


OLS_GD_OPTIMIZERS = [OLSGradientDescent, OLSMomentum, OLSAdaGrad, OLSRMSProp, OLSAdam]
RIDGE_GD_OPTIMIZERS = [
    RidgeGradientDescent,
    RidgeMomentum,
    RidgeAdaGrad,
    RidgeRMSProp,
    RidgeAdam,
]
LASSO_GD_OPTIMIZERS = [
    LASSOGradientDescent,
    LASSOMomentum,
    LASSOAdaGrad,
    LASSORMSProp,
    LASSOAdam,
]

OLS_SGD_OPTIMIZERS = [
    OLSStochasticGradientDescent,
    OLSMomentumSGD,
    OLSAdaGradSGD,
    OLSRMSPropSGD,
    OLSAdamSGD,
]
RIDGE_SGD_OPTIMIZERS = [
    RidgeStochasticGradientDescent,
    RidgeMomentumSGD,
    RidgeAdaGradSGD,
    RidgeRMSPropSGD,
    RidgeAdamSGD,
]
LASSO_SGD_OPTIMIZERS = [
    LASSOStochasticGradientDescent,
    LASSOMomentumSGD,
    LASSOAdaGradSGD,
    LASSORMSPropSGD,
    LASSOAdamSGD,
]
