import torch
from torch import nn


class CausalSelfAttention(nn.Module):

    def __init__(self, n_embd, n_head, causal=True, bias=False):
        super().__init__()
        assert n_embd % n_head == 0
        # key, query, value projections for all heads, but in a batch
        self.c_attn = nn.Linear(n_embd, 3 * n_embd, bias=bias)
        # output projection
        self.c_proj = nn.Linear(n_embd, n_embd, bias=bias)
        # regularization
        self.n_head = n_head
        self.n_embd = n_embd
        self.causal = causal

    def forward(self, x):
        B, T, C = x.size() # batch size, sequence length, embedding dimensionality (n_embd)

        # calculate query, key, values for all heads in batch and move head forward to be the batch dim
        q, k ,v  = self.c_attn(x).split(self.n_embd, dim=2)
        k = k.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
        q = q.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)
        v = v.view(B, T, self.n_head, C // self.n_head).transpose(1, 2) # (B, nh, T, hs)

        # causal self-attention; Self-attend: (B, nh, T, hs) x (B, nh, hs, T) -> (B, nh, T, T)
        # efficient attention using Flash Attention CUDA kernels
        y = torch.nn.functional.scaled_dot_product_attention(q, k, v, attn_mask=None, is_causal=self.causal)
        y = y.transpose(1, 2).contiguous().view(B, T, C) # re-assemble all head outputs side by side

        # output projection
        y = self.c_proj(y)
        return y


class MLP(nn.Module):

    def __init__(self, n_embd, bias=False):
        super().__init__()
        self.c_fc    = nn.Linear(n_embd, 4 * n_embd, bias=bias)
        self.c_proj  = nn.Linear(4 * n_embd, n_embd, bias=bias)
        self.gelu = nn.GELU()

    def forward(self, x):
        x = self.c_fc(x)
        x = self.gelu(x)
        x = self.c_proj(x)
        return x


class Block(nn.Module):

    def __init__(self, n_embd, n_head, causal=True, bias=False):
        super().__init__()
        self.ln_1 = nn.LayerNorm(n_embd)
        self.attn = CausalSelfAttention(n_embd, n_head, causal=causal, bias=bias)
        self.ln_2 = nn.LayerNorm(n_embd)
        self.mlp = MLP(n_embd, bias=bias)

    def forward(self, x):
        x = x + self.attn(self.ln_1(x))
        x = x + self.mlp(self.ln_2(x))
        return x


class Transformer(nn.Module):
    def __init__(
        self,
        dimension,
        causal=True,
        num_layers=2,
        skip=True,
        max_block_size=50,
        n_head=16,
        bias=False,
    ):
        super().__init__()
        assert(dimension in (1024, 2048))
        if dimension == 1024:
            n_head = 8
        elif dimension == 2048:
            n_head = 16
        assert(dimension % n_head == 0)
        self.skip = skip
        self.dimension = dimension
        self.max_block_size = max_block_size
        self.transformer = nn.ModuleDict(dict(
            wpe=nn.Embedding(max_block_size, dimension),
            h=nn.ModuleList([
                Block(dimension, n_head, bias=bias, causal=causal) for _ in range(num_layers)
            ]),
        ))

    def forward(self, x):
        # torch.Size([16, 2048, 50])
        device = x.device
        b, n_emb, t = x.size()
        if n_emb != self.dimension:
            print("robin", x.size())
            print("alfred", self.dimension)
        assert(n_emb == self.dimension)
        x = x.permute(0, 2, 1)
        # (b, t, n_embd)
        pos = torch.arange(0, t, dtype=torch.long, device=device).unsqueeze(0)  # shape (1, t)
        pos_emb = self.transformer.wpe(pos)  # position embeddings of shape (1, t, n_embd)
        y = x + pos_emb
        for block in self.transformer.h:
            y = block(y)
        if self.skip:
            y = y + x
        y = y.permute(0, 2, 1)
        # (b, n_embd, t)
        return y


class StreamableLSTM(nn.Module):
    """LSTM without worrying about the hidden state, nor the layout of the data.
    Expects input as convolutional layout.
    """
    def __init__(self, dimension: int, num_layers: int = 2, skip: bool = True):
        super().__init__()
        self.skip = skip
        self.lstm = nn.LSTM(dimension, dimension, num_layers)

    def forward(self, x):
        x = x.permute(2, 0, 1)
        y, _ = self.lstm(x)
        if self.skip:
            y = y + x
        y = y.permute(1, 2, 0)
        return y
