import torch
import math
import torch.optim as optim
import gc
from einops import rearrange
from torch import Tensor, nn

#torch.cuda.empty_cache()
#gc.collect()

class ResidualAttentionTranspose(nn.Module):
    def __init__(self, dim):
        super(ResidualAttentionTranspose, self).__init__()
        self.linear1 = ResidualLinear(dim, dim)
        self.attenton = nn.MultiheadAttention(dim, 8, batch_first=True)
                

    def forward(self, x):
        shape = x.shape
        x = x.flatten(2).transpose(1, 2)
        x = self.linear1(x)
        attn_output, attn_weights = self.attenton(x, x, x)

        return (x + attn_output).view(shape)
    
class ResidualAttentionFlatten(nn.Module):
    def __init__(self, dim):
        super(ResidualAttentionFlatten, self).__init__()
        self.linear1 = ResidualLinear(dim, dim)
        encoder_layer = nn.TransformerEncoderLayer(d_model=dim, nhead=8)
        self.transformer_encoder = nn.TransformerEncoder(encoder_layer, num_layers=3)
        #self.attenton = nn.MultiheadAttention(dim, 8, batch_first=True)
                

    def forward(self, x):
        shape = x.shape
        x = x.flatten(2)
        x = self.linear1(x)
        #attn_output, attn_weights = self.attenton(x, x, x)
        attn_output = self.transformer_encoder(x)


        return (x + attn_output).view(shape)
print_shape = True

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

    def forward(self, x):
        if(print_shape):
    	    print(x.shape)
        return x

class PositionEmbedding(nn.Module):
    def __init__(self, dim):
        super(PositionEmbedding, self).__init__()
        self.register_buffer("position_ids", torch.arange(256).expand((1, -1)), persistent=False)
        self.position_embedding = nn.Embedding(256, dim)
        
    def forward(self, x):
        shape = x.shape
        
        #x = x.flatten(2).transpose(1, 2)
        embeddings = self.position_embedding(self.position_ids)
        #x =  x + embeddings

        return x + embeddings
        #return x.transpose(2, 1).view(shape)

class MultiScaleConvModule(nn.Module):
    def __init__(self, in_channels, out_channels):
        super(MultiScaleConvModule, self).__init__()
        
        # Define 3 parallel convolutional layers with different kernel sizes
        self.conv4 = nn.Conv2d(in_channels, out_channels, kernel_size=4, padding=1, stride=2)
        self.conv8 = nn.Conv2d(in_channels, out_channels, kernel_size=8, padding=3, stride=2)
        self.conv16 = nn.Conv2d(in_channels, out_channels, kernel_size=16, padding=7, stride=2)

    def forward(self, x):
        x1 = (self.conv4(x))
        x2 = (self.conv8(x))
        x3 = (self.conv16(x))

        out = torch.cat([x1, x2, x3], dim=1)
        out = F.relu(out)
        
        return out

class FeatureEnhancer(nn.Module):
    def __init__(self, channel_count, input_dim):
        super(FeatureEnhancer, self).__init__()
        hidden_dim = input_dim // 16

        self.conv = nn.Sequential(
            nn.Conv2d(in_channels=channel_count, out_channels=channel_count, kernel_size=16, stride=4, padding=6),
            nn.ReLU(),
            #nn.Conv2d(in_channels=channel_count, out_channels=channel_count, kernel_size=3, stride=2, padding=1),
			)
        self.attn = ResidualAttentionFlatten(hidden_dim)
        #self.ff = nn.Linear(hidden_dim, input_dim)
        self.upconv = nn.ConvTranspose2d(channel_count, channel_count, kernel_size=4, stride=4)
        print(hidden_dim)

    def forward(self, x):
        shape = x.shape
        #print(shape)
        y = self.conv(x)
        y = self.attn(y)
        y = self.upconv(y)#(y.flatten(2))
        #print(y.shape)

        return x + y.view(shape)

class AttnBlock(nn.Module):
    def __init__(self, in_channels: int):
        super().__init__()
        self.in_channels = in_channels

        self.norm = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)

        self.q = nn.Conv2d(in_channels, in_channels, kernel_size=1)
        self.k = nn.Conv2d(in_channels, in_channels, kernel_size=1)
        self.v = nn.Conv2d(in_channels, in_channels, kernel_size=1)
        self.proj_out = nn.Conv2d(in_channels, in_channels, kernel_size=1)

    def attention(self, h_: Tensor) -> Tensor:
        h_ = self.norm(h_)
        q = self.q(h_)
        k = self.k(h_)
        v = self.v(h_)

        b, c, h, w = q.shape
        q = rearrange(q, "b c h w -> b 1 (h w) c").contiguous()
        k = rearrange(k, "b c h w -> b 1 (h w) c").contiguous()
        v = rearrange(v, "b c h w -> b 1 (h w) c").contiguous()
        h_ = nn.functional.scaled_dot_product_attention(q, k, v)

        return rearrange(h_, "b 1 (h w) c -> b c h w", h=h, w=w, c=c, b=b)

    def forward(self, x: Tensor) -> Tensor:
        return x + self.proj_out(self.attention(x))


class CrossAttention(nn.Module):
    def __init__(self, q_dim, kv_dim, attn_dim, num_heads):
        super(CrossAttention, self).__init__()
        self.query_proj = nn.Linear(q_dim, attn_dim)
        self.key_proj = nn.Linear(kv_dim, attn_dim)
        self.value_proj = nn.Linear(kv_dim, attn_dim)
        self.multihead_attn = nn.MultiheadAttention(embed_dim=attn_dim, num_heads=num_heads)

    def forward(self, query, key, value):
        query = self.query_proj(query)
        key = self.key_proj(key)
        value = self.value_proj(value)
        attn_output, _ = self.multihead_attn(query, key, value)
        return attn_output
    
class SelfAttention(nn.Module):
    def __init__(self, embed_dim, num_heads):
        super(SelfAttention, self).__init__()
        self.attention = nn.MultiheadAttention(embed_dim=512, num_heads=num_heads)
        
        self.down = nn.Linear(embed_dim, 512)
        self.up = nn.Linear(512, embed_dim)

    def forward(self, x):
        # x shape: (batch_size, num_patches, embed_dim)
        x = x.transpose(0, 1)  # Required shape for nn.MultiheadAttention: (num_patches, batch_size, embed_dim)
        down = self.down(x)

        attn_output, _ = self.attention(down, down, down)

        x += self.up(attn_output)
        return x.transpose(0, 1)  # Back to (batch_size, num_patches, embed_dim)

    
class PatchEmbedding(nn.Module):
    def __init__(self, in_channels, embed_dim, patch_size=16):
        super(PatchEmbedding, self).__init__()
        self.proj = nn.Conv2d(in_channels, embed_dim, kernel_size=patch_size, stride=patch_size)
        self.pos = PositionEmbedding(embed_dim)
    def forward(self, x):
        x = self.proj(x)  # Shape: (batch_size, embed_dim, H/patch_size, W/patch_size)
        x = x.flatten(2)  # Flatten to (batch_size, embed_dim, num_patches)
        x = x.transpose(1, 2)  # Shape: (batch_size, num_patches, embed_dim)
        x = self.pos(x)
        return x
    
class Unpatch(nn.Module):
    def __init__(self, size):
        super(Unpatch, self).__init__()
        self.size = size
        
    def forward(self, x):
        batch_size, num_patches, embed_dim = x.shape
        x = x.transpose(1, 2)
        x= x.reshape(batch_size, -1, self.size, self.size)
        
        return x

def swish(x: Tensor) -> Tensor:
    return x * torch.sigmoid(x)

class ResnetBlock(nn.Module):
    def __init__(self, in_channels: int, out_channels: int):
        super().__init__()
        self.in_channels = in_channels
        out_channels = in_channels if out_channels is None else out_channels
        self.out_channels = out_channels

        self.norm1 = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True)
        self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1)
        self.norm2 = nn.GroupNorm(num_groups=32, num_channels=out_channels, eps=1e-6, affine=True)
        self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1)
        if self.in_channels != self.out_channels:
            self.nin_shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, padding=0)

    def forward(self, x):
        h = x
        h = self.norm1(h)
        h = swish(h)
        h = self.conv1(h)

        h = self.norm2(h)
        h = swish(h)
        h = self.conv2(h)

        if self.in_channels != self.out_channels:
            x = self.nin_shortcut(x)

        return x + h

class DownscaledAttention(nn.Module):
    def __init__(self, in_channels: int, attn_channels: int, down_factor: int):
        super().__init__()

        self.attn = AttnBlock(attn_channels)
        self.norm1 = nn.GroupNorm(num_groups=32, num_channels=attn_channels, eps=1e-6, affine=True)
        self.conv1 = nn.Conv2d(in_channels, attn_channels, kernel_size=3, stride=1, padding=1)

        self.down = nn.ModuleList()
        self.up = nn.ModuleList()

        self.down.append(nn.Conv2d(in_channels, attn_channels, kernel_size=3, stride=1, padding=0))
        for i in range(down_factor):
            self.down.append(nn.Conv2d(attn_channels, attn_channels, kernel_size=3, stride=2, padding=1))

        for i in range(down_factor-1):
            self.up.append(nn.ConvTranspose2d(attn_channels, attn_channels, kernel_size=4, stride=2, padding=1))
           # self.up.append(PrintShape())
        self.up.append(nn.ConvTranspose2d(attn_channels, in_channels, kernel_size=4, stride=2, padding=1))

    def forward(self, x):
        h = x

        #print(f"Pre {h.shape}")
        for i, block in enumerate(self.down):
            h = swish(block(h))

        #print(f"Middle {h.shape}")
        h = self.attn(h)

        for i, block in enumerate(self.up):
            h = block(h)
        #print(f"Post {h.shape}")

        return x + h

class DifferenceAE(nn.Module):
    def __init__(self, input_channels=3, attn_channels=4, num_heads=4, attn_dim=128):
        super(DifferenceAE, self).__init__()
        self.encoder = nn.Sequential(
            #MultiScaleConvModule(in_channels=3, out_channels=16),
            nn.Conv2d( in_channels=3, out_channels=128, kernel_size=3, stride=2, padding=1),
            DownscaledAttention(128, 64, 4),
            PrintShape(),
            nn.ReLU(),    
            nn.Conv2d( in_channels=128, out_channels=96, kernel_size=3, stride=2, padding=1),
            DownscaledAttention(96, 64, 3),
            PrintShape(),
            nn.ReLU(),    
            nn.Conv2d( in_channels=96, out_channels=64, kernel_size=3, stride=2, padding=1),
            DownscaledAttention(64, 64, 2),
            PrintShape(),
            nn.ReLU(),    
            nn.Conv2d( in_channels=64, out_channels=32, kernel_size=3, stride=1, padding=1),
            #DownscaledAttention(32, 64, 1),
            PrintShape(),
            # ResnetBlock(32, 32),
            # ResnetBlock(32, 32),
            # ResnetBlock(32, 32),
            # ResnetBlock(32, 32),
            nn.ReLU(),    
            nn.Conv2d( in_channels=32, out_channels=16, kernel_size=3, stride=1, padding=1),
            PrintShape(),
            )

        #self.cross_attention = self.cross_attention = CrossAttention(q_dim=attn_dim, kv_dim=attn_channels*32*32, attn_dim=attn_dim, num_heads=num_heads)

        self.decoder = nn.Sequential(
            #AttnBlock(4),
            nn.ConvTranspose2d(in_channels=16, out_channels=32, kernel_size=3, stride=1, padding=1),
            ResnetBlock(32, 32),
            AttnBlock(32),
            ResnetBlock(32, 32),
            AttnBlock(32),
            ResnetBlock(32, 32),
            AttnBlock(32),
            ResnetBlock(32, 32),
            AttnBlock(32),
            PrintShape(),
            nn.ReLU(),
            nn.ConvTranspose2d(in_channels=32, out_channels=16, kernel_size=4, stride=2, padding=1),
            PrintShape(),
            nn.ReLU(),
            nn.ConvTranspose2d(in_channels=16, out_channels=8, kernel_size=4, stride=2, padding=1),
            PrintShape(),
            nn.ReLU(),
            nn.ConvTranspose2d(in_channels=8, out_channels=6, kernel_size=4, stride=2, padding=1 ),
            nn.ReLU(),
            PrintShape(),
            nn.ConvTranspose2d(in_channels=6, out_channels=4, kernel_size=3, stride=1, padding=1 ),
            PrintShape(),
            nn.ReLU(),
            nn.ConvTranspose2d(in_channels=4, out_channels=3, kernel_size=3, stride=1, padding=1 ),

            )
        
        
    def encode(self , x):
        return self.encoder(x)
    
    def decode(self , x):
        decoded_residual = self.decoder(x)
        return decoded_residual

    def forward(self, frame1):
        x = self.encode(frame1)
        return self.decode(x)


model = DifferenceAE().to('cuda')

#linear = ResidualAttention(768)

frames = torch.rand(1, 3, 512, 512).to('cuda')
tensor = torch.rand(1, 3, 512, 512).to('cuda')
encoded = model.encode(frames)
print(f'encoded: {encoded.shape}')
tensor = model.decode(encoded)
print(f'decoded: {tensor.shape}')

criterion = nn.MSELoss()
optimizer = optim.Adam(model.parameters(), lr=1e-4)
torch.cuda.empty_cache()
print_shape = False

improve architecture, im traiining on 512x512 images with 3 channels