
import torch
import torch.nn as nn
import math
import json
from torch.utils.checkpoint import checkpoint
import sys
import os
from torch import Tensor
import numpy as np
from typing import Optional, Tuple
import torch.nn.functional as F
from model import Embeddings

curr_path = os.path.dirname(os.path.realpath(__file__))
sys.path.append(curr_path)

class SoftmaxAttention(nn.Module):
    def __init__(self, config):
        super().__init__()
        self.drop_attn = torch.nn.Dropout(p = config["dropout_prob"])
        self.head_dim = config["head_dim"]

    def forward(self, Q, K, V, mask):
        dot = torch.matmul(Q, torch.transpose(K, -2, -1))
        dot = dot / math.sqrt(self.head_dim)
        dot = dot - 1e6 * (1 - mask[:, None, None, :])

        attn = nn.functional.softmax(dot, dim = -1)
        attn = self.drop_attn(attn)

        X = torch.matmul(attn, V)
        return X

class DotProductAttention(nn.Module):
    r"""

    Args: dim, mask
        dim (int): dimension of attention
        mask (torch.Tensor): tensor containing indices to be masked

    Inputs: query, key, value, mask
        - **query** (batch, q_len, d_model): tensor containing projection vector for decoders.
        - **key** (batch, k_len, d_model): tensor containing projection vector for encoders.
        - **value** (batch, v_len, d_model): tensor containing features of the encoded input sequence.
        - **mask** (-): tensor containing indices to be masked

    Returns: context, attn
        - **context**: tensor containing the context vector from attention mechanism.
        - **attn**: tensor containing the attention (alignment) from the encoders outputs.
    """
    def __init__(self, dim: int, scale: bool = True) -> None:
        super(DotProductAttention, self).__init__()
        if scale:
            self.sqrt_dim = np.sqrt(dim)
        else:
            self.sqrt_dim = 1

    def forward(
            self,
            query: torch.FloatTensor,
            key: torch.FloatTensor,
            value: torch.FloatTensor,
            mask: Optional[torch.FloatTensor] = None,
    ) -> Tuple[torch.FloatTensor, torch.FloatTensor]:
        score = torch.matmul(query, key.transpose(2, 3)) / self.sqrt_dim

        # if mask is not None:
        #     score.masked_fill_(mask, -1e4)
        if mask is not None:
            score = score - 1e6 * (1 - mask[:, None, None, :])

        attn = F.softmax(score, -1)

        if len(query.size()) == 3:
            context = torch.bmm(attn, value)
        else:
            context = torch.matmul(attn, value)

        return context, None

class Attention(nn.Module):
    def __init__(self, config):
        super().__init__()

        self.dim = config["dim"]
        self.head_dim = config["head_dim"]
        self.num_head = config["num_head"]
        self.attn_cpt = config["attn_cpt"] if "attn_cpt" in config else False

        self.W_q = nn.Linear(self.dim, self.num_head * self.head_dim)
        self.W_k = nn.Linear(self.dim, self.num_head * self.head_dim)
        self.W_v = nn.Linear(self.dim, self.num_head * self.head_dim)

        self.attn = SoftmaxAttention(config)

        self.ff = nn.Linear(self.num_head * self.head_dim, self.dim)

    def forward(self, X, mask):

        Q = self.split_heads(self.W_q(X))
        K = self.split_heads(self.W_k(X))
        V = self.split_heads(self.W_v(X))

        with torch.cuda.amp.autocast(enabled = False):
            attn_out = self.attn(Q.float(), K.float(), V.float(), mask.float())
                
        attn_out = self.combine_heads(attn_out)

        out = self.ff(attn_out)

        return out

    def combine_heads(self, X):
        X = X.transpose(1, 2)
        X = X.reshape(X.size(0), X.size(1), self.num_head * self.head_dim)
        return X

    def split_heads(self, X):
        X = X.reshape(X.size(0), X.size(1), self.num_head, self.head_dim)
        X = X.transpose(1, 2)
        return X



class MultiHeadAttention(nn.Module):
    r"""
    Multi-Head Attention proposed in "Attention Is All You Need"
    Instead of performing a single attention function with d_model-dimensional keys, values, and queries,
    project the queries, keys and values h times with different, learned linear projections to d_head dimensions.
    These are concatenated and once again projected, resulting in the final values.
    Multi-head attention allows the model to jointly attend to information from different representation
    subspaces at different positions.

    MultiHead(Q, K, V) = Concat(head_1, ..., head_h) · W_o
        where head_i = Attention(Q · W_q, K · W_k, V · W_v)

    Args:
        dim (int): The dimension of model (default: 512)
        num_attention_heads (int): The number of attention heads. (default: 8)

    Inputs: query, key, value, mask
        - **query** (batch, q_len, d_model): tensor containing projection vector for decoders.
        - **key** (batch, k_len, d_model): tensor containing projection vector for encoders.
        - **value** (batch, v_len, d_model): tensor containing features of the encoded input sequence.
        - **mask** (-): tensor containing indices to be masked

    Returns: output, attn
        - **output** (batch, output_len, dimensions): tensor containing the attended output features.
        - **attn** (batch * num_attention_heads, v_len): tensor containing the attention (alignment) from the encoders outputs.
    """
    def __init__(self, dim: int = 512, num_attention_heads: int = 8) -> None:
        super(MultiHeadAttention, self).__init__()

        assert dim % num_attention_heads == 0, "hidden_dim % num_attention_heads should be zero."

        self.d_head = int(dim / num_attention_heads)
        self.num_attention_heads = num_attention_heads
        self.query_proj = nn.Linear(dim, self.d_head * num_attention_heads)
        self.key_proj = nn.Linear(dim, self.d_head * num_attention_heads)
        self.value_proj = nn.Linear(dim, self.d_head * num_attention_heads)
        self.scaled_dot_attn = DotProductAttention(dim, scale=True)

    def forward(
            self,
            query: torch.FloatTensor,
            key: torch.FloatTensor,
            value: torch.FloatTensor,
            mask: Optional[torch.FloatTensor] = None,
    ) -> Tuple[torch.FloatTensor, torch.FloatTensor]:
        batch_size = value.size(0)

        query = self.query_proj(query).view(batch_size, -1, self.num_attention_heads, self.d_head).transpose(1, 2)
        key = self.key_proj(key).view(batch_size, -1, self.num_attention_heads, self.d_head).transpose(1, 2)
        value = self.value_proj(value).view(batch_size, -1, self.num_attention_heads, self.d_head).transpose(1, 2)

        # if mask is not None:
        #     mask = mask.unsqueeze(1).repeat(1, self.num_attention_heads, 1, 1)

        context, attn = self.scaled_dot_attn(query, key, value, mask)

        context = context.transpose(1, 2).reshape(batch_size, -1, self.num_attention_heads * self.d_head)

        return context, attn


class LinearUnifiedNestedAttention(nn.Module):
    def __init__(self, dim, num_attention_heads: int = 8) -> None:
        super(LinearUnifiedNestedAttention, self).__init__()
        self.pack_attention = MultiHeadAttention(dim, num_attention_heads)
        self.unpack_attention = MultiHeadAttention(dim, num_attention_heads)

    def forward(
            self,
            query: torch.FloatTensor,
            key: torch.FloatTensor,
            value: torch.FloatTensor,
            p: torch.FloatTensor,
            attention_padding_mask: torch.BoolTensor = None,
    ) -> Tuple[torch.FloatTensor, torch.FloatTensor]:
        packed_context, _ = self.pack_attention(p, key, value, attention_padding_mask)
        unpacked_context, _ = self.unpack_attention(query, packed_context, packed_context)
        return unpacked_context, packed_context

class LunaTransformerEncoderLayer(nn.Module):
    def __init__(self, config):
        super().__init__()

        # self.norm1 = nn.LayerNorm(config["dim"])
        # self.mha = Attention(config)
        # self.dropout1 = torch.nn.Dropout(p = config["dropout_prob"])
        # self.norm2 = nn.LayerNorm(config["dim"])

        # self.mlpblock = nn.Sequential(
        #     nn.Linear(config["dim"], config["hidden_dim"]),
        #     nn.GELU(),
        #     torch.nn.Dropout(p = config["dropout_prob"]),
        #     nn.Linear(config["hidden_dim"], config["dim"]),
        #     torch.nn.Dropout(p = config["dropout_prob"])
        # )
        self.luna_attention = LinearUnifiedNestedAttention(config["dim"], config["num_head"])
        self.feed_forward = nn.Sequential(
            nn.Linear(config["dim"], config["hidden_dim"]),
            nn.GELU(),
            torch.nn.Dropout(p = config["dropout_prob"]),
            nn.Linear(config["hidden_dim"], config["dim"]),
            torch.nn.Dropout(p = config["dropout_prob"])
        )
        self.packed_context_layer_norm = nn.LayerNorm(config["dim"])
        self.unpacked_context_layer_norm = nn.LayerNorm(config["dim"])
        self.unpacked_context_layer_norm = nn.LayerNorm(config["dim"])
        self.feed_forward_layer_norm = nn.LayerNorm(config["dim"])


    def forward(self, inputs, p, mask):
        # X = self.dropout1(self.mha(self.norm1(X), mask)) + X
        # X = self.mlpblock(self.norm2(X)) + X
        unpacked_context, packed_context = self.luna_attention(
            query=inputs,
            key=inputs,
            value=inputs,
            p=p,
            attention_padding_mask=mask,
        )
        packed_context = self.packed_context_layer_norm(packed_context + p)
        unpacked_context = self.unpacked_context_layer_norm(unpacked_context + inputs)

        outputs = self.feed_forward(unpacked_context)
        outputs = self.feed_forward_layer_norm(outputs + unpacked_context)
        return outputs, packed_context

class Backbone(nn.Module):
    def __init__(self, config):
        super().__init__()

        self.num_layers = config["num_layers"]
        self.shared_weight = config["shared_weight"]
        
        self.d_model = config["dim"]
        self.projected_embedding_length = config["project_embedding_length"]

        # self.projected_embeddings = nn.Parameter(torch.Tensor(self.projected_embedding_length, self.d_model))
        # nn.init.normal_(self.projected_embeddings, mean=0.0, std=self.d_model ** -0.5)

        # self.projected_positions = PositionalEncoding(self.d_model, self.projected_embedding_length)
        # self.position_embeddings = nn.Embedding(config["max_seq_len"], config["embedding_dim"])
        # torch.nn.init.normal_(self.position_embeddings.weight, std = 0.02)

        # self.projected_positions = nn.Embedding(self.projected_embedding_length, config["embedding_dim"])
        # torch.nn.init.normal_(self.projected_positions.weight, std = 0.02)
        config_projection = {"embedding_dim": config["embedding_dim"], "dim": config["dim"], \
                             "max_seq_len": self.projected_embedding_length, "vocab_size": self.projected_embedding_length,
                             "model_type": "luna_transformer"}
        self.project_embedding = Embeddings(config_projection)

        # self.input_embedding = nn.Embedding(config["vocab_size"], config["embedding_dim"])
        self.dropout = nn.Dropout(p=config["dropout_prob"])
        # self.input_positions = PositionalEncoding(self.d_model, config["max_seq_len"])
        # self.input_norm = nn.LayerNorm(self.d_model)
        # self.embed_scale = math.sqrt(self.d_model)

        self.encoders = nn.ModuleList([LunaTransformerEncoderLayer(config) for _ in range(self.num_layers)])

        self.norm = nn.LayerNorm(config["dim"])

    def forward(self, X, mask):
        batch_size, seq_length, dim = X.size()

        # embedded = self.input_embedding(X)

        # embedded *= self.embed_scale
        # projected_embedded = self.projected_embeddings * self.embed_scale
        embedded = X
        # embedded += self.input_positions(embedded.size(1))
        # projected_embedded = self.projected_embeddings
        position_ids = torch.arange(self.projected_embedding_length, dtype = torch.long, device = X.device)[None, :]
        # projected_embedded += self.projected_positions(self.projected_embedding_length).squeeze(0)
        # position_embedded = self.projected_positions(position_ids)
        # projected_embedded = self.projected_embeddings + position_embedded
        projected_embedded = self.project_embedding(position_ids).squeeze()

        # projected_embedded += 
        seq_length, dim = projected_embedded.size()
        projected_embedded = projected_embedded.unsqueeze(0).expand(batch_size, seq_length, dim)
        outputs = self.dropout(embedded)
        p = self.dropout(projected_embedded)

        for encoder in self.encoders:
            outputs, p = encoder(outputs, p, mask)
            # X = encoder(X, mask)

        outputs = self.norm(outputs) * mask[:, :, None]

        return outputs

class PositionalEncoding(nn.Module):
    """
    Positional Encoding proposed in "Attention Is All You Need".
    Since transformer contains no recurrence and no convolution, in order for the model to make
    use of the order of the sequence, we must add some positional information.

    "Attention Is All You Need" use sine and cosine functions of different frequencies:
        PE_(pos, 2i)    =  sin(pos / power(10000, 2i / d_model))
        PE_(pos, 2i+1)  =  cos(pos / power(10000, 2i / d_model))
    """
    def __init__(self, d_model: int = 80, max_length: int = 5000) -> None:
        super(PositionalEncoding, self).__init__()
        pe = torch.zeros(max_length, d_model, requires_grad=False)
        position = torch.arange(0, max_length, dtype=torch.float).unsqueeze(1).float()
        div_term = torch.exp(torch.arange(0, d_model, 2).float() * -(math.log(10000.0) / d_model))
        pe[:, 0::2] = torch.sin(position * div_term)
        pe[:, 1::2] = torch.cos(position * div_term)
        pe = pe.unsqueeze(0)
        self.register_buffer('pe', pe)

    def forward(self, length: int) -> Tensor:
        return self.pe[:, :length]