from itertools import product
import torch
from torch.optim._functional import sgd , adam
from torch.optim import Optimizer
from icecream import ic


def project_grads(grads, V, keep_idx, noise=False, noise_fac=None):
    device = grads.device
    V_ = V[:, keep_idx]
    V_T = V_.T
    grads_ = torch.einsum('ij,j->i', V_T, grads)
    grads_ = torch.einsum('ij,j->i', V_, grads_)
    if noise:
        discard_idx = torch.logical_not(keep_idx)
        discard_cnt = discard_idx.sum()
        V__ = V[:, discard_idx]
        if noise_fac is None:
            noise_fac = grads.abs().mean()
        noise = noise_fac * torch.randn(discard_cnt, device=device) # TODO: Replace with mu * sigma * eps
        # TODO: Set noise level for each dimension through running mean
        noise_ = torch.einsum('ij,j->i', V__, noise)
        grads_ += noise_
    return grads_


def calc_update(grads, hessian, lr, k=None, prune_set = 'chaotic', prune_k = 'top_abs', noise=False, noise_fac=None, ret_idx=False):
    """ Calculates eigenvalue decomposition of Hessian and updates gradient.
    
    Parameters:
    -----------
    grads: torch.Tensor
        A 1D gradient tensor
    hessian: torch.tensor
        A 2D Hessian tensor
    lr: float
        The learning rate of the GD algorithm
    k: int, optional
        If specified, will only prune (at most) k eigenvectors
    prune_set: str, optional
        One of 'pos', 'neg', 'chaotic'.
        Determines subset from which to prune eigenpairs.
        (Positive, negative or eigenvalues indicating chaotic behaviour)
    prune_k: str, optional
        One of 'top_abs', 'rand'.
        Determines the strategy for pruning eigenvalues from the pruning set.
        (Largest by absolute value or random selection)
    noise: bool, optional
        If set, will not prune the eigenvalues but add random Gaussian noise on respective directions.
    noise_fac: float, optional
        If noise is set, will scale Gaussian noise with the noise_level specified.
    ret_idx: bool, optional
        If ret_idx is set, will return indices of pruned eigenpairs.
    """
    n = hessian.shape[0]
    device = grads.device
    grads = grads.clone().reshape(-1)
    w, V = torch.linalg.eigh(hessian)
    if prune_set == 'neg':
        if k is not None:
            keep_idx = torch.empty(len(w), dtype=torch.bool, device=device)
            keep_idx[:] = 1
            if prune_k == 'top_abs':
                discard_idx = torch.topk(w, k, largest=False)[1]
            elif prune_k == 'rand':
                neg_idx = torch.where(w <= 0)[0]
                k_ = min(k, len(neg_idx))
                discard_idx = torch.randint(0, len(neg_idx), (k_, ), device=device)
                discard_idx = neg_idx[discard_idx]
            keep_idx[discard_idx] = 0
            keep_idx[w > 0] = 1
        else:
            keep_idx = w > 0
    elif prune_set == 'pos':    
        if k is not None:
            keep_idx = torch.empty(len(w), dtype=torch.bool, device=device)
            keep_idx[:] = 1
            if prune_k == 'top_abs':
                discard_idx = torch.topk(w, k)[1]
            elif prune_k == 'rand':
                pos_idx = torch.where(w >= 0)[0]
                k_ = min(k, len(pos_idx))
                discard_idx = torch.randint(0, len(pos_idx), (k_, ), device=device)
                discard_idx = pos_idx[discard_idx]
            keep_idx[discard_idx] = 0
            keep_idx[w < 0] = 1
        else:
            keep_idx = w < 0
    elif prune_set == 'chaotic':
        w_ = (1 - lr * w)**2
        if k is not None:
            keep_idx = torch.empty(len(w_), dtype=torch.bool, device=device)
            keep_idx[:] = 1
            if prune_k == 'top_abs':
                discard_idx = torch.topk(w_, k)[1]
            elif prune_k == 'rand':
                chaos_idx = torch.where(w_ > 1)[0]
                k_ = min(k, len(chaos_idx))
                discard_idx = torch.randint(0, len(chaos_idx), (k_, ), device=device)
                discard_idx = chaos_idx[discard_idx]
            keep_idx[discard_idx] = 0
            keep_idx[w_ <= 1] = 1
        else:
            keep_idx = w_ <= 1
    grads_ = project_grads(grads, V, keep_idx, noise=noise, noise_fac=noise_fac)
    if ret_idx:
        return grads_, keep_idx
    else:
        return grads_
    
    
def calc_update_global(grads, wg, Vg, k=None, prune_k='top_abs', noise=False, noise_fac=None, ret_idx=False):
    device = grads.device
    if k is not None:
        keep_idx = torch.empty(len(wg), dtype=torch.bool, device=device)
        keep_idx[:] = 1
        if prune_k == 'top_abs':
            discard_idx = torch.topk(wg, k)[1]
        elif prune_k == 'rand':
            chaos_idx = torch.where(wg >= 1)[0]
            k_ = min(k, len(chaos_idx))
            discard_idx = torch.randint(0, len(chaos_idx), (k_, ), device=device)
            discard_idx = chaos_idx[discard_idx]
        keep_idx[discard_idx] = 0
        keep_idx[wg < 1] = 1
    else:
        keep_idx = wg < 1
    grads_ = project_grads(grads, Vg, keep_idx, noise=noise, noise_fac=noise_fac)
    if ret_idx:
        return grads_, keep_idx
    else:
        return grads_


class CGD(Optimizer):
    r"""Implements stochastic gradient descent (optionally with momentum).

    Nesterov momentum is based on the formula from
    `On the importance of initialization and momentum in deep learning`__.

    Args:
        params (iterable): iterable of parameters to optimize or dicts defining
            parameter groups
        lr (float): learning rate
        momentum (float, optional): momentum factor (default: 0)
        weight_decay (float, optional): weight decay (L2 penalty) (default: 0)
        dampening (float, optional): dampening for momentum (default: 0)
        nesterov (bool, optional): enables Nesterov momentum (default: False)

    Example:
        >>> optimizer = torch.optim.SGD(model.parameters(), lr=0.1, momentum=0.9)
        >>> optimizer.zero_grad()
        >>> loss_fn(model(input), target).backward()
        >>> optimizer.step()

    __ http://www.cs.toronto.edu/%7Ehinton/absps/momentum.pdf

    .. note::
        The implementation of SGD with Momentum/Nesterov subtly differs from
        Sutskever et. al. and implementations in some other frameworks.

        Considering the specific case of Momentum, the update can be written as

        .. math::
            \begin{aligned}
                v_{t+1} & = \mu * v_{t} + g_{t+1}, \\
                p_{t+1} & = p_{t} - \text{lr} * v_{t+1},
            \end{aligned}

        where :math:`p`, :math:`g`, :math:`v` and :math:`\mu` denote the
        parameters, gradient, velocity, and momentum respectively.

        This is in contrast to Sutskever et. al. and
        other frameworks which employ an update of the form

        .. math::
            \begin{aligned}
                v_{t+1} & = \mu * v_{t} + \text{lr} * g_{t+1}, \\
                p_{t+1} & = p_{t} - v_{t+1}.
            \end{aligned}

        The Nesterov version is analogously modified.
    """

    def __init__(self, params, 
                 lr=1e-3, 
                 momentum=0, 
                 dampening=0,
                 weight_decay=0, 
                 nesterov=False, 
                 gamma=1.0, 
                 k=None, 
                 prune_global=False, 
                 prune_start=0, 
                 prune_set='chaotic', 
                 prune_k='top_abs', 
                 noise=False, 
                 noise_fac=None, 
                 wg=None, 
                 Vg=None, 
                 ret_idx=False):
        if lr < 0.0:
            raise ValueError("Invalid learning rate: {}".format(lr))
        if momentum < 0.0:
            raise ValueError("Invalid momentum value: {}".format(momentum))
        if weight_decay < 0.0:
            raise ValueError("Invalid weight_decay value: {}".format(weight_decay))

        defaults = dict(lr=lr, momentum=momentum, dampening=dampening,
                        weight_decay=weight_decay, nesterov=nesterov)
        if nesterov and (momentum <= 0 or dampening != 0):
            raise ValueError("Nesterov momentum requires a momentum and zero dampening")
        is_wg, is_Vg = isinstance(wg, torch.Tensor), isinstance(Vg, torch.Tensor) 
        if prune_global and not (is_wg and is_Vg):
            raise TypeError("If prune_global is set, both wg and Vg need to be provided!")
        super().__init__(params, defaults)
        self.gamma = gamma
        self.k = k
        self.prune_global = prune_global
        self.prune_set = prune_set
        self.prune_start = prune_start
        self.prune_k = prune_k
        self.noise = noise
        self.noise_fac = noise_fac
        self.wg = wg
        self.Vg = Vg
        self.ret_idx = ret_idx
        self.state['step'] = 0

    def __setstate__(self, state):
        super().__setstate__(state)
        for group in self.param_groups:
            group.setdefault('nesterov', False)

    @torch.no_grad()
    def step(self, hessian: torch.Tensor = None, closure=None):
        """Performs a single optimization step.

        Args:
            closure (callable, optional): A closure that reevaluates the model
                and returns the loss.
        """
        loss = None
        if closure is not None:
            with torch.enable_grad():
                loss = closure()

        for group in self.param_groups:
            params_with_grad = []
            d_p_list = []
            momentum_buffer_list = []
            weight_decay = group['weight_decay']
            momentum = group['momentum']
            dampening = group['dampening']
            nesterov = group['nesterov']
            lr = group['lr']
            
            grads = torch.cat([p.grad.reshape(-1) for p in group['params']])
            if self.is_pruning():
                if self.ret_idx:
                    if self.prune_global:
                        projection, keep_idx = calc_update_global(grads, self.wg, self.Vg, 
                                                                  k=self.k, 
                                                                  prune_k=self.prune_k, 
                                                                  noise=self.noise, 
                                                                  noise_fac=self.noise_fac, 
                                                                  ret_idx=True)
                    else:
                        projection, keep_idx = calc_update(grads, hessian, lr, 
                                                            k=self.k, 
                                                            prune_set=self.prune_set, 
                                                            prune_k=self.prune_k, 
                                                            noise=self.noise, 
                                                            noise_fac=self.noise_fac, 
                                                            ret_idx=True)
                    self.state['last_kept'] = keep_idx
                else:
                    if self.prune_global:
                        projection = calc_update_global(grads, self.wg, self.Vg, 
                                                                  k=self.k, 
                                                                  prune_k=self.prune_k, 
                                                                  noise=self.noise, 
                                                                  noise_fac=self.noise_fac) 
                    else:
                        projection = calc_update(grads, hessian, lr, 
                                                k=self.k, 
                                                prune_set=self.prune_set, 
                                                prune_k=self.prune_k, 
                                                noise=self.noise, 
                                                noise_fac=self.noise_fac)
            high = 0
            for p in group['params']:
                if p.grad is not None:
                    params_with_grad.append(p)
                    # d_p_list.append(p.grad)
                    low = high
                    high = low + p.numel()
                    if self.is_pruning():
                        d_p = projection[low:high].reshape(p.shape)
                        d_p = (1 - self.gamma) * p.grad + self.gamma * d_p
                    else:
                        d_p = p.grad
                    d_p_list.append(d_p)

                    state = self.state[p]
                    if 'momentum_buffer' not in state:
                        momentum_buffer_list.append(None)
                    else:
                        momentum_buffer_list.append(state['momentum_buffer'])

            sgd(params_with_grad,
                  d_p_list,
                  momentum_buffer_list,
                  weight_decay=weight_decay,
                  momentum=momentum,
                  lr=lr,
                  dampening=dampening,
                  nesterov=nesterov)

            # update momentum_buffers in state
            for p, momentum_buffer in zip(params_with_grad, momentum_buffer_list):
                state = self.state[p]
                state['momentum_buffer'] = momentum_buffer
            self.state['step'] += 1

        return loss
    
    def is_pruning(self):
        return self.state['step'] >= self.prune_start
    
    def get_last_idx_kept(self):
        return self.state['last_kept']