from typing import Union
import numpy as np
import torch
import torch.nn as nn
import torch.nn.functional as F
from einops import rearrange
from math import log2, ceil

from dac.nn.layers import WNConv1d
from dac.nn.vae import VAEBottleneck


def log(t, eps=1e-5):
    return t.clamp(min=eps).log()


def entropy(prob):
    return (-prob * log(prob)).sum(dim=-1)


class VectorQuantize(nn.Module):
    """
    Implementation of VQ similar to Karpathy's repo:
    https://github.com/karpathy/deep-vector-quantization
    Additionally uses following tricks from Improved VQGAN
    (https://arxiv.org/pdf/2110.04627.pdf):
        1. Factorized codes: Perform nearest neighbor lookup in low-dimensional space
            for improved codebook usage
        2. l2-normalized codes: Converts euclidean distance to cosine similarity which
            improves training stability
    """

    def __init__(self, input_dim: int, codebook_size: int, codebook_dim: int):
        super().__init__()
        self.codebook_size = codebook_size
        self.codebook_dim = codebook_dim

        self.in_proj = WNConv1d(input_dim, codebook_dim, kernel_size=1)
        self.out_proj = WNConv1d(codebook_dim, input_dim, kernel_size=1)
        self.codebook = nn.Embedding(codebook_size, codebook_dim)

    def forward(self, z):
        """Quantized the input tensor using a fixed codebook and returns
        the corresponding codebook vectors

        Parameters
        ----------
        z : Tensor[B x D x T]

        Returns
        -------
        Tensor[B x D x T]
            Quantized continuous representation of input
        Tensor[1]
            Commitment loss to train encoder to predict vectors closer to codebook
            entries
        Tensor[1]
            Codebook loss to update the codebook
        Tensor[B x T]
            Codebook indices (quantized discrete representation of input)
        Tensor[B x D x T]
            Projected latents (continuous representation of input before quantization)
        """

        # Factorized codes (ViT-VQGAN) Project input into low-dimensional space
        z_e = self.in_proj(z)  # z_e : (B x D x T)
        z_q, indices = self.decode_latents(z_e)

        commitment_loss = F.mse_loss(z_e, z_q.detach(), reduction="none").mean([1, 2])
        codebook_loss = F.mse_loss(z_q, z_e.detach(), reduction="none").mean([1, 2])

        z_q = (
            z_e + (z_q - z_e).detach()
        )  # noop in forward pass, straight-through gradient estimator in backward pass

        z_q = self.out_proj(z_q)

        # orthogonal_loss
        b, n, d = z_q.shape
        normed_codes = F.normalize(z_q, dim=-1)
        cosine_sim = normed_codes @ normed_codes.transpose(1, 2)
        orthogonal_loss = (cosine_sim**2).sum() / (b * n**2) - (1 / n)

        return z_q, commitment_loss, codebook_loss, orthogonal_loss, indices, z_e

    def embed_code(self, embed_id):
        return F.embedding(embed_id, self.codebook.weight)

    def decode_code(self, embed_id):
        return self.embed_code(embed_id).transpose(1, 2)

    def decode_latents(self, latents):
        encodings = rearrange(latents, "b d t -> (b t) d")
        codebook = self.codebook.weight  # codebook: (N x D)

        # L2 normalize encodings and codebook (ViT-VQGAN)
        encodings = F.normalize(encodings)
        codebook = F.normalize(codebook)

        # Compute euclidean distance with codebook
        dist = (
            encodings.pow(2).sum(1, keepdim=True)
            - 2 * encodings @ codebook.t()
            + codebook.pow(2).sum(1, keepdim=True).t()
        )
        indices = rearrange((-dist).max(1)[1], "(b t) -> b t", b=latents.size(0))
        z_q = self.decode_code(indices)
        return z_q, indices


class LFQuantize(nn.Module):
    """
    Implementation of VQ similar to Karpathy's repo:
    https://github.com/karpathy/deep-vector-quantization
    Additionally uses following tricks from Improved VQGAN
    (https://arxiv.org/pdf/2110.04627.pdf):
        1. Factorized codes: Perform nearest neighbor lookup in low-dimensional space
            for improved codebook usage
        2. l2-normalized codes: Converts euclidean distance to cosine similarity which
            improves training stability
    """

    def __init__(self, input_dim: int, codebook_size: int):
        super().__init__()
        assert codebook_size == 2 ** ceil(
            log2(codebook_size)
        ), "Codebook size must be a power of 2"
        self.codebook_size = codebook_size
        self.codebook_dim = int(log2(codebook_size))
        self.register_buffer("mask", 2 ** torch.arange(self.codebook_dim))

        self.in_proj = WNConv1d(input_dim, self.codebook_dim, kernel_size=1)
        self.out_proj = WNConv1d(self.codebook_dim, input_dim, kernel_size=1)

    def forward(self, z):
        """Quantized the input tensor using a fixed codebook and returns
        the corresponding codebook vectors

        Parameters
        ----------
        z : Tensor[B x D x T]

        Returns
        -------
        Tensor[B x D x T]
            Quantized continuous representation of input
        Tensor[1]
            Commitment loss to train encoder to predict vectors closer to codebook
            entries
        Tensor[1]
            Codebook loss to update the codebook
        Tensor[B x T]
            Codebook indices (quantized discrete representation of input)
        Tensor[B x D x T]
            Projected latents (continuous representation of input before quantization)
        """

        # Factorized codes (ViT-VQGAN) Project input into low-dimensional space
        z_e = self.in_proj(z)  # z_e : (B x D x T)
        z_q = torch.where(z_e > 0, 1.0, -1.0).detach()
        indices = self.bits_to_index(z_q)
        commitment_loss = F.mse_loss(z_e, z_q.detach(), reduction="none").mean([1, 2])

        z_q = (
            z_e + (z_q - z_e).detach()
        )  # noop in forward pass, straight-through gradient estimator in backward pass

        z_q = self.out_proj(z_q)

        # orthogonal_loss
        b, n, d = z_q.shape
        normed_codes = F.normalize(z_q, dim=-1)
        cosine_sim = normed_codes @ normed_codes.transpose(1, 2)
        orthogonal_loss = (cosine_sim**2).sum() / (b * n**2) - (1 / n)

        return (
            z_q,
            commitment_loss,
            commitment_loss * 0.0,
            orthogonal_loss,
            indices,
            z_e,
        )

    @torch.no_grad()
    def bits_to_index(self, bits: torch.Tensor):
        """
        Convert bits to index

        Parameters
        ----------
        bits : Tensor[B x N x T]
            Quantized discrete representation of input"""

        b, n, t = bits.shape
        bits = rearrange(bits, "b n t -> (b t) n")
        bits = (bits > 0).float()
        indices = bits.float() @ self.mask.float()
        return rearrange(indices, "(b t) -> b t", b=b).int().detach()


class PassthroughQuantize(nn.Module):
    """
    Dont quantize the input, just pass it through
    """

    def __init__(self):
        super().__init__()

    def forward(self, z, *args, **kwargs):
        """Quantized the input tensor using a fixed codebook and returns
        the corresponding codebook vectors
        """

        # return Return(z, None, z, 0, 0)
        return {
            "z": z,
            "latents": z,
        }


class ResidualOp(nn.Module):
    def forward(self, z, z_q):
        return z - z_q


class MLPResidualOp(nn.Module):
    def __init__(self, input_dim: int):
        super().__init__()
        hidden_dim = input_dim * 4
        self.mlp = nn.Sequential(
            nn.Linear(input_dim * 2, hidden_dim),
            nn.GELU(),
            nn.Linear(hidden_dim, input_dim),
        )

    def forward(self, z, z_q):
        t = self.mlp(torch.cat([z, z_q], dim=1).transpose(1, 2)).transpose(1, 2)
        assert t.shape == z.shape
        return z - t


class ResidualVectorQuantize(nn.Module):
    """
    Introduced in SoundStream: An end2end neural audio codec
    https://arxiv.org/abs/2107.03312
    """

    def __init__(
        self,
        input_dim: int = 512,
        n_codebooks: int = 9,
        codebook_size: int = 1024,
        codebook_dim: Union[int, list] = 8,
        quantizer_dropout: float = 0.0,
        use_vae: bool = False,
        residual_type: str = "subtract",
        use_lfq: bool = False,
    ):
        super().__init__()
        if isinstance(codebook_dim, int):
            codebook_dim = [codebook_dim for _ in range(n_codebooks)]

        self.n_codebooks = n_codebooks
        self.codebook_dim = codebook_dim
        self.codebook_size = codebook_size
        self.use_vae = use_vae
        if use_vae:
            self.vae = VAEBottleneck(input_dim, input_dim)

        if use_lfq:
            self.quantizers = nn.ModuleList(
                [LFQuantize(input_dim, codebook_size) for _ in range(n_codebooks)]
            )
        else:
            self.quantizers = nn.ModuleList(
                [
                    VectorQuantize(input_dim, codebook_size, codebook_dim[i])
                    for i in range(n_codebooks)
                ]
            )
        self.quantizer_dropout = quantizer_dropout

        self.residual_type = residual_type
        if residual_type == "mlp":
            self.residuals = nn.ModuleList(
                [MLPResidualOp(input_dim) for _ in range(n_codebooks - 1)]
            )
        elif residual_type == "subtract":
            self.residuals = nn.ModuleList(
                [ResidualOp() for _ in range(n_codebooks - 1)]
            )

    def forward(self, z, n_quantizers: int = None):
        """Quantized the input tensor using a fixed set of `n` codebooks and returns
        the corresponding codebook vectors
        Parameters
        ----------
        z : Tensor[B x D x T]
        n_quantizers : int, optional
            No. of quantizers to use
            (n_quantizers < self.n_codebooks ex: for quantizer dropout)
            Note: if `self.quantizer_dropout` is True, this argument is ignored
                when in training mode, and a random number of quantizers is used.
        Returns
        -------
        dict
            A dictionary with the following keys:

            "z" : Tensor[B x D x T]
                Quantized continuous representation of input
            "codes" : Tensor[B x N x T]
                Codebook indices for each codebook
                (quantized discrete representation of input)
            "latents" : Tensor[B x N*D x T]
                Projected latents (continuous representation of input before quantization)
            "vq/commitment_loss" : Tensor[1]
                Commitment loss to train encoder to predict vectors closer to codebook
                entries
            "vq/codebook_loss" : Tensor[1]
                Codebook loss to update the codebook
        """

        out = {}
        if self.use_vae:
            vae = self.vae(z)
            z = vae["z"]
            out.update(vae)

        z_q = 0
        residual = z
        commitment_loss = 0
        codebook_loss = 0
        orthogonal_loss = 0

        codebook_indices = []
        latents = []

        if n_quantizers is None:
            n_quantizers = self.n_codebooks
        if self.training:
            n_quantizers = torch.ones((z.shape[0],)) * self.n_codebooks + 1
            dropout = torch.randint(1, self.n_codebooks + 1, (z.shape[0],))
            n_dropout = int(z.shape[0] * self.quantizer_dropout)
            n_quantizers[:n_dropout] = dropout[:n_dropout]
            n_quantizers = n_quantizers.to(z.device)

        for i, quantizer in enumerate(self.quantizers):
            if self.training is False and i >= n_quantizers:
                break

            (
                z_q_i,
                commitment_loss_i,
                codebook_loss_i,
                orthogonal_loss_i,
                indices_i,
                z_e_i,
            ) = quantizer(residual)

            # Create mask to apply quantizer dropout
            mask = (
                torch.full((z.shape[0],), fill_value=i, device=z.device) < n_quantizers
            )
            z_q = z_q + z_q_i * mask[:, None, None]

            if i < len(self.residuals):
                residual = self.residuals[i](residual, z_q_i)

            # Sum losses
            commitment_loss += (commitment_loss_i * mask).mean()
            codebook_loss += (codebook_loss_i * mask).mean()
            orthogonal_loss += (orthogonal_loss_i * mask).mean()

            codebook_indices.append(indices_i)
            latents.append(z_e_i)

        codes = torch.stack(codebook_indices, dim=1)
        latents = torch.cat(latents, dim=1)

        out.update(
            {
                "z": z_q,
                "codes": codes,
                "latents": latents,
                "vq/commitment_loss": commitment_loss,
                "vq/codebook_loss": codebook_loss,
                "vq/orthogonal_loss": orthogonal_loss,
            }
        )
        return out

    def from_codes(self, codes: torch.Tensor):
        """Given the quantized codes, reconstruct the continuous representation
        Parameters
        ----------
        codes : Tensor[B x N x T]
            Quantized discrete representation of input
        Returns
        -------
        Tensor[B x D x T]
            Quantized continuous representation of input
        """
        z_q = 0.0
        z_p = []
        n_codebooks = codes.shape[1]
        for i in range(n_codebooks):
            z_p_i = self.quantizers[i].decode_code(codes[:, i, :])
            z_p.append(z_p_i)

            z_q_i = self.quantizers[i].out_proj(z_p_i)
            z_q = z_q + z_q_i
        return z_q, torch.cat(z_p, dim=1), codes

    def from_latents(self, latents: torch.Tensor):
        """Given the unquantized latents, reconstruct the
        continuous representation after quantization.

        Parameters
        ----------
        latents : Tensor[B x N x T]
            Continuous representation of input after projection

        Returns
        -------
        Tensor[B x D x T]
            Quantized representation of full-projected space
        Tensor[B x D x T]
            Quantized representation of latent space
        """
        z_q = 0
        z_p = []
        codes = []
        dims = np.cumsum([0] + [q.codebook_dim for q in self.quantizers])

        n_codebooks = np.where(dims <= latents.shape[1])[0].max(axis=0, keepdims=True)[
            0
        ]
        for i in range(n_codebooks):
            j, k = dims[i], dims[i + 1]
            z_p_i, codes_i = self.quantizers[i].decode_latents(latents[:, j:k, :])
            z_p.append(z_p_i)
            codes.append(codes_i)

            z_q_i = self.quantizers[i].out_proj(z_p_i)
            z_q = z_q + z_q_i

        return z_q, torch.cat(z_p, dim=1), torch.stack(codes, dim=1)


class GroupedResidualVectorQuantize(nn.Module):
    """
    Introduced in SoundStream: An end2end neural audio codec
    https://arxiv.org/abs/2107.03312
    """

    def __init__(
        self,
        input_dim: int = 512,
        n_codebooks_per_level: list[int] = [2, 2, 2, 2],
        codebook_size: int = 1024,
        codebook_dim: int = 8,
        quantizer_dropout: float = 0.0,
    ):
        super().__init__()

        self.n_codebooks_per_level = n_codebooks_per_level
        self.input_dim = input_dim
        self.codebook_dim = codebook_dim
        self.codebook_size = codebook_size

        for n_codebooks in n_codebooks_per_level:
            assert (
                input_dim % n_codebooks == 0
            ), f"input_dim ({input_dim}) must be divisible by n_codebooks ({n_codebooks})"

        self.quantizers = nn.ModuleList(
            [
                nn.ModuleList(
                    [
                        VectorQuantize(
                            input_dim // n_codebooks, codebook_size, codebook_dim
                        )
                        for _ in range(n_codebooks)
                    ]
                )
                for n_codebooks in n_codebooks_per_level
            ]
        )
        self.quantizer_dropout = quantizer_dropout

    @property
    def n_codebooks(self):
        return sum(self.n_codebooks_per_level)

    @property
    def n_levels(self):
        return len(self.n_codebooks_per_level)

    def forward(self, z, n_quantizers: int = None):
        """Quantized the input tensor using a fixed set of `n` codebooks and returns
        the corresponding codebook vectors
        Parameters
        ----------
        z : Tensor[B x D x T]
        n_quantizers : int, optional
            No. of quantizers to use
            (n_quantizers < self.n_codebooks ex: for quantizer dropout)
            Note: if `self.quantizer_dropout` is True, this argument is ignored
                when in training mode, and a random number of quantizers is used.
        Returns
        -------
        dict
            A dictionary with the following keys:

            "z" : Tensor[B x D x T]
                Quantized continuous representation of input
            "codes" : Tensor[B x N x T]
                Codebook indices for each codebook
                (quantized discrete representation of input)
            "latents" : Tensor[B x N*D x T]
                Projected latents (continuous representation of input before quantization)
            "vq/commitment_loss" : Tensor[1]
                Commitment loss to train encoder to predict vectors closer to codebook
                entries
            "vq/codebook_loss" : Tensor[1]
                Codebook loss to update the codebook
        """
        z_q = torch.zeros_like(z)
        # residual = z
        commitment_loss = 0
        codebook_loss = 0
        orthogonal_loss = 0

        codebook_indices = []
        latents = []

        if n_quantizers is None:
            n_quantizers = self.n_codebooks
        if self.training:
            n_quantizers = torch.ones((z.shape[0],)) * self.n_codebooks + 1
            dropout = torch.randint(1, self.n_codebooks + 1, (z.shape[0],))
            n_dropout = int(z.shape[0] * self.quantizer_dropout)
            n_quantizers[:n_dropout] = dropout[:n_dropout]
            n_quantizers = n_quantizers.to(z.device)

        for i, quantizers in enumerate(self.quantizers):
            if self.training is False and i >= n_quantizers:
                break
            split_size = z.shape[1] // len(quantizers)
            for j, quantizer in enumerate(quantizers):
                split_slice = slice(j * split_size, (j + 1) * split_size)
                (
                    z_q_i,
                    commitment_loss_i,
                    codebook_loss_i,
                    orthogonal_loss_i,
                    indices_i,
                    z_e_i,
                ) = quantizer(z[:, split_slice, :] - z_q[:, split_slice, :])

                # Create mask to apply quantizer dropout
                quantizer_idx = sum(self.n_codebooks_per_level[:i]) + j
                mask = (
                    torch.full((z.shape[0],), fill_value=quantizer_idx, device=z.device)
                    < n_quantizers
                )
                z_q[:, split_slice, :] = (
                    z_q[:, split_slice, :] + z_q_i * mask[:, None, None]
                )

                # Sum losses
                commitment_loss += (commitment_loss_i * mask).mean()
                codebook_loss += (codebook_loss_i * mask).mean()
                orthogonal_loss += (orthogonal_loss_i * mask).mean()

                codebook_indices.append(indices_i)
                latents.append(z_e_i)

        codes = torch.stack(codebook_indices, dim=1)
        latents = torch.cat(latents, dim=1)

        return {
            "z": z_q,
            "codes": codes,
            "latents": latents,
            "vq/commitment_loss": commitment_loss,
            "vq/codebook_loss": codebook_loss,
            "vq/orthogonal_loss": orthogonal_loss,
        }

    def from_codes(self, codes: torch.Tensor):
        """Given the quantized codes, reconstruct the continuous representation
        Parameters
        ----------
        codes : Tensor[B x N x T]
            Quantized discrete representation of input
        Returns
        -------
        Tensor[B x D x T]
            Quantized continuous representation of input
        """
        B, N, T = codes.shape
        z_q = torch.zeros(B, self.input_dim, T, device=codes.device)
        z_p = []
        cb_idx = 0
        for i in range(self.n_levels):
            z_p_i = []
            split_size = self.input_dim // self.n_codebooks_per_level[i]
            for j, quantizer in enumerate(self.quantizers[i]):
                z_p_i_j = quantizer.decode_code(codes[:, cb_idx, :])
                z_p_i.append(z_p_i_j)

                z_q_i = quantizer.out_proj(z_p_i_j)
                z_q[:, split_size * j : split_size * (j + 1), :] += z_q_i
                cb_idx += 1
            z_p.append(torch.cat(z_p_i, dim=1))

        return z_q, torch.cat(z_p, dim=1), codes

    def from_latents(self, latents: torch.Tensor):
        """Given the unquantized latents, reconstruct the
        continuous representation after quantization.

        Parameters
        ----------
        latents : Tensor[B x N x T]
            Continuous representation of input after projection

        Returns
        -------
        Tensor[B x D x T]
            Quantized representation of full-projected space
        Tensor[B x D x T]
            Quantized representation of latent space
        """
        raise NotImplementedError
        z_q = 0
        z_p = []
        codes = []
        dims = np.cumsum([0] + [q.codebook_dim for q in self.quantizers])

        n_codebooks = np.where(dims <= latents.shape[1])[0].max(axis=0, keepdims=True)[
            0
        ]
        for i in range(n_codebooks):
            j, k = dims[i], dims[i + 1]
            z_p_i, codes_i = self.quantizers[i].decode_latents(latents[:, j:k, :])
            z_p.append(z_p_i)
            codes.append(codes_i)

            z_q_i = self.quantizers[i].out_proj(z_p_i)
            z_q = z_q + z_q_i

        return z_q, torch.cat(z_p, dim=1), torch.stack(codes, dim=1)


@torch.no_grad()
@torch.inference_mode()
def main():
    rvq = ResidualVectorQuantize(quantizer_dropout=True).eval()
    x = torch.randn(16, 512, 80)
    y = rvq(x)
    print(y["latents"].shape)
    assert x.shape == y["z"].shape, f"{x.shape} != {y['z'].shape}"

    from_codes = rvq.from_codes(y["codes"])
    assert x.shape == from_codes[0].shape
    assert x.shape == y["z"].shape, f"{x.shape} != {y['z'].shape}"
    assert torch.allclose(from_codes[0], y["z"], atol=1e-2), from_codes[0] - y["z"]

    from_latents = rvq.from_latents(y["latents"])
    assert x.shape == from_latents[0].shape
    assert torch.allclose(from_latents[0], y["z"], atol=1e-2), from_latents[0] - y["z"]

    grvq = GroupedResidualVectorQuantize(quantizer_dropout=True).eval()
    y = grvq(x)
    print(y["latents"].shape)
    from_codes = grvq.from_codes(y["codes"])
    assert x.shape == from_codes[0].shape
    assert x.shape == y["z"].shape, f"{x.shape} != {y['z'].shape}"
    assert torch.allclose(from_codes[0], y["z"], atol=1e-2), from_codes[0] - y["z"]

    # from_latents = grvq.from_latents(y["latents"])
    # assert x.shape == from_latents[0].shape
    # assert torch.allclose(from_latents[0], y["z"], atol=1e-2), from_latents[0] - y["z"]


if __name__ == "__main__":
    main()
