import torch
import torch.nn as nn
import numpy as np
from math import sqrt
from utils.masking import TriangularCausalMask, ProbMask
from reformer_pytorch import LSHSelfAttention
from einops import rearrange, repeat


class DSAttention(nn.Module):
    '''De-stationary Attention'''

    def __init__(self, mask_flag=True, factor=5, scale=None, attention_dropout=0.1, output_attention=False):
        super(DSAttention, self).__init__()
        self.scale = scale
        self.mask_flag = mask_flag
        self.output_attention = output_attention
        self.dropout = nn.Dropout(attention_dropout)

    def forward(self, queries, keys, values, attn_mask, tau=None, delta=None):
        B, L, H, E = queries.shape
        _, S, _, D = values.shape
        scale = self.scale or 1. / sqrt(E)

        tau = 1.0 if tau is None else tau.unsqueeze(
            1).unsqueeze(1)  # B x 1 x 1 x 1
        delta = 0.0 if delta is None else delta.unsqueeze(
            1).unsqueeze(1)  # B x 1 x 1 x S

        # De-stationary Attention, rescaling pre-softmax score with learned de-stationary factors
        scores = torch.einsum("blhe,bshe->bhls", queries, keys) * tau + delta

        if self.mask_flag:
            if attn_mask is None:
                attn_mask = TriangularCausalMask(B, L, device=queries.device)

            scores.masked_fill_(attn_mask.mask, -np.inf)

        A = self.dropout(torch.softmax(scale * scores /0.1, dim=-1))
        V = torch.einsum("bhls,bshd->blhd", A, values)

        if self.output_attention:
            return V.contiguous(), A
        else:
            return V.contiguous(), None


class FullAttention(nn.Module):
    def __init__(self, mask_flag=True, factor=5, scale=None, attention_dropout=0.1, output_attention=False, temp=1):
        super(FullAttention, self).__init__()
        self.scale = scale
        self.mask_flag = mask_flag
        self.output_attention = output_attention
        self.dropout = nn.Dropout(attention_dropout)
        self.temp = temp # default : 1

    def forward(self, queries, keys, values, attn_mask, tau=None, delta=None):
        B, L, H, E = queries.shape
        _, S, _, D = values.shape
        scale = self.scale or 1. / sqrt(E)

        scores = torch.einsum("blhe,bshe->bhls", queries, keys)

        if self.mask_flag and attn_mask is not None:
            # Handle boolean tensor masks for padded tokens
            if isinstance(attn_mask, torch.Tensor) and attn_mask.dtype == torch.bool:
                # For boolean masks where True means "keep" and False means "mask"
                # We need to convert to a format where False positions get -inf
                # First expand mask for batch and head dimensions
                # attn_mask shape: [B, S] -> [B, 1, 1, S]
                expanded_mask = ~attn_mask.unsqueeze(1).unsqueeze(1)
                scores.masked_fill_(expanded_mask, -float('inf'))
            else:
                # Original triangle mask case
                if attn_mask is None:
                    attn_mask = TriangularCausalMask(B, L, device=queries.device)
                scores.masked_fill_(attn_mask.mask, -float('inf'))
                
        A = self.dropout(torch.softmax(scale * scores/self.temp, dim=-1))
        V = torch.einsum("bhls,bshd->blhd", A, values)
        
        if self.output_attention:
            # A : Attention map
            return V.contiguous(), A
        else:
            return V.contiguous(), None


class FullAttention_temp(nn.Module):
    def __init__(self, mask_flag=True, factor=5, scale=None, attention_dropout=0.1, output_attention=False, temp=1, num_global_tokens=1):
        super(FullAttention_temp, self).__init__()
        self.scale = scale
        self.mask_flag = mask_flag
        self.output_attention = output_attention
        self.dropout = nn.Dropout(attention_dropout)
        self.temp = temp
        self.num_global_tokens = num_global_tokens
        self.min_temp = 0.01
        self.max_temp = 1
        
        # Add attention biasing for improved channel token relationships
        self.use_attn_bias = False
        if self.use_attn_bias:
            self.attn_bias = nn.Parameter(torch.zeros(1, 1, 1, 1))


    def forward(self, queries, keys, values, attn_mask, tau=None, delta=None):
        B, L, H, E = queries.shape
        _, S, _, D = values.shape
        scale = self.scale or 1. / sqrt(E)

        scores = torch.einsum("blhe,bshe->bhls", queries, keys)
        
        # Add attention bias if enabled
        if self.use_attn_bias:
            scores = scores + self.attn_bias

        if self.mask_flag and attn_mask is not None:
            # Handle boolean tensor masks for padded tokens
            if isinstance(attn_mask, torch.Tensor) and attn_mask.dtype == torch.bool:
                # For boolean masks where True means "keep" and False means "mask"
                # We need to convert to a format where False positions get -inf
                # First expand mask for batch and head dimensions
                # attn_mask shape: [B, S] -> [B, 1, 1, S]
                expanded_mask = ~attn_mask.unsqueeze(1).unsqueeze(1)
                scores.masked_fill_(expanded_mask, -float('inf'))
            else:
                # Original triangle mask case
                if attn_mask is None:
                    attn_mask = TriangularCausalMask(B, L, device=queries.device)
                scores.masked_fill_(attn_mask.mask, -float('inf'))
        
        # Handle token-specific temperature parameter (tau)
        if tau is not None:
            # Check if tau is token-specific (shaped for each token position)
            if isinstance(tau, torch.Tensor) and tau.dim() > 1:
                # For token-specific temperatures
                # Reshape tau to match attention scores dimensions [1, num_tokens, 1]
                # scores shape: [B, H, L, S]
                if tau.size(1) == self.num_global_tokens and L == self.num_global_tokens:
                    # Faster implementation for token-specific temperature
                    # Create a temperature tensor that matches scores dimensions for broadcasting
                    # [1, num_tokens, 1] -> [1, 1, num_tokens, S]
                    temp_expanded = tau.unsqueeze(1).expand(1, H, -1, S)
                    
                    # Apply temperature directly in one operation using broadcasting
                    scores = scores * (1.0 / temp_expanded)
            elif isinstance(self.temp, nn.Parameter): # learned temperature
                temp = torch.clamp(self.temp, self.min_temp, self.max_temp)
                temp_inv = 1.0 / temp.view(1, 1, 1, -1)
                temp_inv = temp_inv.repeat_interleave(self.num_global_tokens)  # [n_vars * num_global_tokens]
                scores = scores * temp_inv
            else: # scalar temperature
                temp_inv = 1/self.temp
                scores = scores * temp_inv
        else:
            # If no tau provided, use default temp parameter
            if isinstance(self.temp, nn.Parameter): # learned temperature
                temp = torch.clamp(self.temp, self.min_temp, self.max_temp)
                temp_inv = 1.0 / temp.view(1, 1, 1, -1)
                temp_inv = temp_inv.repeat_interleave(self.num_global_tokens)  # [n_vars * num_global_tokens]
                scores = scores * temp_inv
            else: # scalar temperature
                temp_inv = 1/self.temp
                scores = scores * temp_inv
        
        # Sharper attention distribution for global tokens
        A = self.dropout(torch.softmax(scale * scores, dim=-1))
        V = torch.einsum("bhls,bshd->blhd", A, values)
        
        if self.output_attention:
            return V.contiguous(), A
        else:
            return V.contiguous(), None


class ProbAttention(nn.Module):
    def __init__(self, mask_flag=True, factor=5, scale=None, attention_dropout=0.1, output_attention=False):
        super(ProbAttention, self).__init__()
        self.factor = factor
        self.scale = scale
        self.mask_flag = mask_flag
        self.output_attention = output_attention
        self.dropout = nn.Dropout(attention_dropout)

    def _prob_QK(self, Q, K, sample_k, n_top):  # n_top: c*ln(L_q)
        # Q [B, H, L, D]
        B, H, L_K, E = K.shape
        _, _, L_Q, _ = Q.shape

        # calculate the sampled Q_K
        K_expand = K.unsqueeze(-3).expand(B, H, L_Q, L_K, E)
        # real U = U_part(factor*ln(L_k))*L_q
        index_sample = torch.randint(L_K, (L_Q, sample_k))
        K_sample = K_expand[:, :, torch.arange(
            L_Q).unsqueeze(1), index_sample, :]
        Q_K_sample = torch.matmul(
            Q.unsqueeze(-2), K_sample.transpose(-2, -1)).squeeze()

        # find the Top_k query with sparisty measurement
        M = Q_K_sample.max(-1)[0] - torch.div(Q_K_sample.sum(-1), L_K)
        M_top = M.topk(n_top, sorted=False)[1]

        # use the reduced Q to calculate Q_K
        Q_reduce = Q[torch.arange(B)[:, None, None],
                   torch.arange(H)[None, :, None],
                   M_top, :]  # factor*ln(L_q)
        Q_K = torch.matmul(Q_reduce, K.transpose(-2, -1))  # factor*ln(L_q)*L_k

        return Q_K, M_top


class AttentionLayer(nn.Module):
    def __init__(self, attention, d_model, n_heads, d_keys=None,
                 d_values=None):
        super(AttentionLayer, self).__init__()

        d_keys = d_keys or (d_model // n_heads)
        d_values = d_values or (d_model // n_heads)

        self.inner_attention = attention
        self.query_projection = nn.Linear(d_model, d_keys * n_heads)
        self.key_projection = nn.Linear(d_model, d_keys * n_heads)
        self.value_projection = nn.Linear(d_model, d_values * n_heads)
        self.out_projection = nn.Linear(d_values * n_heads, d_model)
        self.n_heads = n_heads
        

    def forward(self, queries, keys, values, attn_mask, tau=None, delta=None):
        B, L, _ = queries.shape
        _, S, _ = keys.shape
        H = self.n_heads

        queries = self.query_projection(queries).view(B, L, H, -1)
        keys = self.key_projection(keys).view(B, S, H, -1)
        values = self.value_projection(values).view(B, S, H, -1)
        # attention map 받기 위해서 수정
        out, attn = self.inner_attention(
            queries,
            keys,
            values,
            attn_mask,
            tau=tau,
            delta=delta
        )
        # attention map 안 쓸 때
        # out = self.inner_attention(
        #     queries,
        #     keys,
        #     values,
        #     attn_mask,
        #     tau=tau,
        #     delta=delta
        # )
        out = out.view(B, L, -1)

        return self.out_projection(out), attn


class ReformerLayer(nn.Module):
    def __init__(self, attention, d_model, n_heads, d_keys=None,
                 d_values=None, causal=False, bucket_size=4, n_hashes=4):
        super().__init__()
        self.bucket_size = bucket_size
        self.attn = LSHSelfAttention(
            dim=d_model,
            heads=n_heads,
            bucket_size=bucket_size,
            n_hashes=n_hashes,
            causal=causal
        )

    def fit_length(self, queries):
        # inside reformer: assert N % (bucket_size * 2) == 0
        B, N, C = queries.shape
        if N % (self.bucket_size * 2) == 0:
            return queries
        else:
            # fill the time series
            fill_len = (self.bucket_size * 2) - (N % (self.bucket_size * 2))
            return torch.cat([queries, torch.zeros([B, fill_len, C]).to(queries.device)], dim=1)

    def forward(self, queries, keys, values, attn_mask, tau, delta):
        # in Reformer: defalut queries=keys
        B, N, C = queries.shape
        queries = self.attn(self.fit_length(queries))[:, :N, :]
        return queries, None


class TwoStageAttentionLayer(nn.Module):
    '''
    The Two Stage Attention (TSA) Layer
    input/output shape: [batch_size, Data_dim(D), Seg_num(L), d_model]
    '''

    def __init__(self, configs,
                 seg_num, factor, d_model, n_heads, d_ff=None, dropout=0.1):
        super(TwoStageAttentionLayer, self).__init__()
        d_ff = d_ff or 4 * d_model
        self.time_attention = AttentionLayer(FullAttention(False, configs.factor, attention_dropout=configs.dropout,
                                                           output_attention=False), d_model, n_heads)
        self.dim_sender = AttentionLayer(FullAttention(False, configs.factor, attention_dropout=configs.dropout,
                                                       output_attention=False), d_model, n_heads)
        self.dim_receiver = AttentionLayer(FullAttention(False, configs.factor, attention_dropout=configs.dropout,
                                                         output_attention=False), d_model, n_heads)
        self.router = nn.Parameter(torch.randn(seg_num, factor, d_model))

        self.dropout = nn.Dropout(dropout)

        self.norm1 = nn.LayerNorm(d_model)
        self.norm2 = nn.LayerNorm(d_model)
        self.norm3 = nn.LayerNorm(d_model)
        self.norm4 = nn.LayerNorm(d_model)

        self.MLP1 = nn.Sequential(nn.Linear(d_model, d_ff),
                                  nn.GELU(),
                                  nn.Linear(d_ff, d_model))
        self.MLP2 = nn.Sequential(nn.Linear(d_model, d_ff),
                                  nn.GELU(),
                                  nn.Linear(d_ff, d_model))

    def forward(self, x, attn_mask=None, tau=None, delta=None):
        # Cross Time Stage: Directly apply MSA to each dimension
        batch = x.shape[0]
        time_in = rearrange(x, 'b ts_d seg_num d_model -> (b ts_d) seg_num d_model')
        time_enc, attn = self.time_attention(
            time_in, time_in, time_in, attn_mask=None, tau=None, delta=None
        )
        dim_in = time_in + self.dropout(time_enc)
        dim_in = self.norm1(dim_in)
        dim_in = dim_in + self.dropout(self.MLP1(dim_in))
        dim_in = self.norm2(dim_in)

        # Cross Dimension Stage: use a small set of learnable vectors to aggregate and distribute messages to build the D-to-D connection
        dim_send = rearrange(dim_in, '(b ts_d) seg_num d_model -> (b seg_num) ts_d d_model', b=batch)
        batch_router = repeat(self.router, 'seg_num factor d_model -> (repeat seg_num) factor d_model', repeat=batch)
        dim_buffer, attn = self.dim_sender(batch_router, dim_send, dim_send, attn_mask=None, tau=None, delta=None)
        dim_receive, attn = self.dim_receiver(dim_send, dim_buffer, dim_buffer, attn_mask=None, tau=None, delta=None)
        dim_enc = dim_send + self.dropout(dim_receive)
        dim_enc = self.norm3(dim_enc)
        dim_enc = dim_enc + self.dropout(self.MLP2(dim_enc))
        dim_enc = self.norm4(dim_enc)

        final_out = rearrange(dim_enc, '(b seg_num) ts_d d_model -> b ts_d seg_num d_model', b=batch)

        return final_out


class FocalSelfAttention(nn.Module):
    def __init__(self, mask_flag=True, factor=5, scale=None, attention_dropout=0.1, output_attention=False, temp=1):
        super(FocalSelfAttention, self).__init__()
        self.scale = scale
        self.mask_flag = mask_flag
        self.output_attention = output_attention
        self.dropout = nn.Dropout(attention_dropout)
        self.temp = temp # default : 1
        
        # 거리에 따른 감쇠 계수 (학습 가능)
        self.distance_decay = nn.Parameter(torch.tensor(1.0))

    def forward(self, queries, keys, values, attn_mask, tau=None, delta=None):
        B, L, H, E = queries.shape
        _, S, _, D = values.shape
        scale = self.scale or 1. / sqrt(E)

        # 1. 기본 어텐션 스코어 계산
        scores = torch.einsum("blhe,bshe->bhls", queries, keys)
        
        # 2. 상대적 위치 인덱스 기반 거리 계산
        # 시계열 데이터에서는 위치 인덱스 차이가 거리
        # 위치 인덱스 그리드 생성 (L x S 크기의 행렬)
        indices = torch.arange(L, device=scores.device)[:, None] - torch.arange(S, device=scores.device)[None, :]
        distances = torch.abs(indices)  # |i-j| 거리 계산
        
        # 3. 거리에 따른 가중치 계산: exp(-distance * decay_factor)
        decay_factor = torch.abs(self.distance_decay).clamp(min=0.1, max=5.0)
        distance_weights = torch.exp(-distances * decay_factor)
        
        # 4. 거리 가중치 텐서를 스코어 차원에 맞게 확장
        # [L, S] -> [1, 1, L, S] -> [B, H, L, S]
        distance_weights = distance_weights.unsqueeze(0).unsqueeze(0).expand_as(scores)
        
        # 5. 거리 가중치를 어텐션 스코어에 적용
        focal_scores = scores * distance_weights
        
        # 6. 필요한 경우 마스킹 적용
        if self.mask_flag and attn_mask is not None:
            if isinstance(attn_mask, torch.Tensor) and attn_mask.dtype == torch.bool:
                expanded_mask = ~attn_mask.unsqueeze(1).unsqueeze(1)
                focal_scores.masked_fill_(expanded_mask, -float('inf'))
            else:
                if attn_mask is None:
                    attn_mask = TriangularCausalMask(B, L, device=queries.device)
                focal_scores.masked_fill_(attn_mask.mask, -float('inf'))
        
        # 7. 어텐션 확률 계산 및 적용
        A = self.dropout(torch.softmax(scale * focal_scores/self.temp, dim=-1))
        V = torch.einsum("bhls,bshd->blhd", A, values)
        
        if self.output_attention:
            return V.contiguous(), A
        else:
            return V.contiguous(), None