# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
from __future__ import annotations

import math
from collections.abc import Iterable

import torch
import torch.nn as nn
import torch.nn.functional as F
from transformers import T5Config
from vllm.distributed import get_tensor_model_parallel_rank, get_tensor_model_parallel_world_size
from vllm.model_executor.layers.activation import get_act_fn
from vllm.model_executor.layers.layernorm import RMSNorm
from vllm.model_executor.layers.linear import (
    ColumnParallelLinear,
    MergedColumnParallelLinear,
    QKVParallelLinear,
    RowParallelLinear,
)
from vllm.model_executor.layers.vocab_parallel_embedding import VocabParallelEmbedding
from vllm.model_executor.model_loader.weight_utils import default_weight_loader


class T5SelfAttention(nn.Module):
    def __init__(
        self,
        config: T5Config,
        has_relative_attention_bias: bool = False,
        prefix: str = "",
    ):
        super().__init__()
        self.d_model = config.d_model
        self.d_kv = config.d_kv
        self.n_heads = config.num_heads
        self.inner_dim = self.n_heads * self.d_kv
        self.has_relative_attention_bias = has_relative_attention_bias
        self.relative_attention_num_buckets = config.relative_attention_num_buckets
        self.relative_attention_max_distance = config.relative_attention_max_distance

        tp_size = get_tensor_model_parallel_world_size()
        assert self.n_heads % tp_size == 0, f"num_heads ({self.n_heads}) must be divisible by tp_size ({tp_size})"
        self.n_heads_per_partition = self.n_heads // tp_size

        # Fused Q/K/V projection, sharded across heads.
        # HF has separate q, k, v — we fuse into qkv_proj and use
        # stacked_params_mapping in load_weights() to load each shard.
        self.qkv_proj = QKVParallelLinear(
            hidden_size=self.d_model,
            head_size=self.d_kv,
            total_num_heads=self.n_heads,
            total_num_kv_heads=self.n_heads,  # T5 uses MHA
            bias=False,
        )

        # Output projection: all-reduce back to full d_model
        # Named ``o`` to match HF's ``SelfAttention.o``
        self.o = RowParallelLinear(
            self.inner_dim,
            self.d_model,
            bias=False,
            input_is_parallel=True,
            return_bias=False,
        )

        if has_relative_attention_bias:
            # Store full embedding; slice heads per rank in forward
            self.relative_attention_bias = nn.Embedding(self.relative_attention_num_buckets, self.n_heads)

    @staticmethod
    def _relative_position_bucket(
        relative_position: torch.Tensor,
        bidirectional: bool = True,
        num_buckets: int = 32,
        max_distance: int = 128,
    ) -> torch.Tensor:
        relative_buckets = 0
        if bidirectional:
            num_buckets //= 2
            relative_buckets += (relative_position > 0).to(torch.long) * num_buckets
            relative_position = torch.abs(relative_position)
        else:
            relative_position = -torch.min(relative_position, torch.zeros_like(relative_position))
        max_exact = num_buckets // 2
        is_small = relative_position < max_exact
        relative_position_if_large = max_exact + (
            torch.log(relative_position.float() / max_exact)
            / math.log(max_distance / max_exact)
            * (num_buckets - max_exact)
        ).to(torch.long)
        relative_position_if_large = torch.min(
            relative_position_if_large,
            torch.full_like(relative_position_if_large, num_buckets - 1),
        )
        relative_buckets += torch.where(is_small, relative_position, relative_position_if_large)
        return relative_buckets

    def compute_bias(self, query_length: int, key_length: int, device: torch.device) -> torch.Tensor:
        """Compute relative position bias, returning only the local head shard."""
        context_position = torch.arange(query_length, dtype=torch.long, device=device)[:, None]
        memory_position = torch.arange(key_length, dtype=torch.long, device=device)[None, :]
        relative_position = memory_position - context_position
        relative_position_bucket = self._relative_position_bucket(
            relative_position,
            bidirectional=True,
            num_buckets=self.relative_attention_num_buckets,
            max_distance=self.relative_attention_max_distance,
        )
        # values: (query_length, key_length, num_heads)
        values = self.relative_attention_bias(relative_position_bucket)
        # Slice to local heads for this TP rank
        tp_rank = get_tensor_model_parallel_rank()
        head_start = tp_rank * self.n_heads_per_partition
        head_end = head_start + self.n_heads_per_partition
        values = values[:, :, head_start:head_end]
        # (1, local_heads, query_length, key_length)
        values = values.permute(2, 0, 1).unsqueeze(0)
        return values

    def forward(
        self,
        hidden_states: torch.Tensor,
        mask: torch.Tensor | None = None,
        position_bias: torch.Tensor | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        batch_size, seq_length = hidden_states.shape[:2]

        # Fused QKV projection
        qkv, _ = self.qkv_proj(hidden_states)
        q_size = self.n_heads_per_partition * self.d_kv
        kv_size = self.n_heads_per_partition * self.d_kv
        query_states, key_states, value_states = qkv.split([q_size, kv_size, kv_size], dim=-1)

        # Reshape: (batch, seq, local_heads, d_kv) -> (batch, local_heads, seq, d_kv)
        query_states = query_states.view(batch_size, seq_length, self.n_heads_per_partition, self.d_kv).transpose(1, 2)
        key_states = key_states.view(batch_size, seq_length, self.n_heads_per_partition, self.d_kv).transpose(1, 2)
        value_states = value_states.view(batch_size, seq_length, self.n_heads_per_partition, self.d_kv).transpose(1, 2)

        # Attention scores: (batch, local_heads, seq, seq)
        scores = torch.matmul(query_states, key_states.transpose(3, 2))

        if position_bias is None:
            if self.has_relative_attention_bias:
                position_bias = self.compute_bias(seq_length, seq_length, device=scores.device)
            else:
                position_bias = torch.zeros(
                    (1, self.n_heads_per_partition, seq_length, seq_length),
                    device=scores.device,
                    dtype=scores.dtype,
                )
            if mask is not None:
                position_bias = position_bias + mask

        scores += position_bias

        attn_weights = F.softmax(scores.float(), dim=-1).type_as(scores)
        attn_output = torch.matmul(attn_weights, value_states)

        # (batch, local_heads, seq, d_kv) -> (batch, seq, local_heads * d_kv)
        attn_output = attn_output.transpose(1, 2).contiguous()
        attn_output = attn_output.view(batch_size, seq_length, -1)

        attn_output = self.o(attn_output)

        return attn_output, position_bias


class T5DenseGatedActDense(nn.Module):
    def __init__(self, config: T5Config, prefix: str = ""):
        super().__init__()
        self.wi = MergedColumnParallelLinear(
            config.d_model,
            [config.d_ff, config.d_ff],
            bias=False,
            gather_output=False,
        )
        self.wo = RowParallelLinear(
            config.d_ff,
            config.d_model,
            bias=False,
            input_is_parallel=True,
            return_bias=False,
        )
        self.act = get_act_fn(config.dense_act_fn)

    def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
        # MergedColumnParallelLinear outputs concatenated [gate, up]
        gate_up, _ = self.wi(hidden_states)
        gate, up = gate_up.chunk(2, dim=-1)
        hidden_states = self.act(gate) * up
        hidden_states = self.wo(hidden_states)
        return hidden_states


class T5DenseActDense(nn.Module):
    def __init__(self, config: T5Config, prefix: str = ""):
        super().__init__()
        self.wi = ColumnParallelLinear(
            config.d_model,
            config.d_ff,
            bias=False,
            gather_output=False,
            return_bias=False,
        )
        self.wo = RowParallelLinear(
            config.d_ff,
            config.d_model,
            bias=False,
            input_is_parallel=True,
            return_bias=False,
        )
        self.act = get_act_fn(config.dense_act_fn)

    def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
        hidden_states = self.wi(hidden_states)
        hidden_states = self.act(hidden_states)
        hidden_states = self.wo(hidden_states)
        return hidden_states


class T5LayerSelfAttention(nn.Module):
    def __init__(self, config: T5Config, has_relative_attention_bias: bool = False, prefix: str = ""):
        super().__init__()
        self.SelfAttention = T5SelfAttention(config, has_relative_attention_bias, prefix=f"{prefix}.SelfAttention")
        self.layer_norm = RMSNorm(config.d_model, eps=config.layer_norm_epsilon)

    def forward(
        self,
        hidden_states: torch.Tensor,
        mask: torch.Tensor | None = None,
        position_bias: torch.Tensor | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        normed = self.layer_norm(hidden_states)
        attn_output, position_bias = self.SelfAttention(normed, mask=mask, position_bias=position_bias)
        hidden_states = hidden_states + attn_output

        # Clamp for fp16 stability (matching HF T5)
        if hidden_states.dtype == torch.float16:
            clamp_value = torch.finfo(hidden_states.dtype).max - 1000
            hidden_states = torch.clamp(hidden_states, min=-clamp_value, max=clamp_value)

        return hidden_states, position_bias


class T5LayerFF(nn.Module):
    def __init__(self, config: T5Config, prefix: str = ""):
        super().__init__()
        if config.is_gated_act:
            self.DenseReluDense = T5DenseGatedActDense(config, prefix=f"{prefix}.DenseReluDense")
        else:
            self.DenseReluDense = T5DenseActDense(config, prefix=f"{prefix}.DenseReluDense")
        self.layer_norm = RMSNorm(config.d_model, eps=config.layer_norm_epsilon)

    def forward(self, hidden_states: torch.Tensor) -> torch.Tensor:
        normed = self.layer_norm(hidden_states)
        ff_output = self.DenseReluDense(normed)
        hidden_states = hidden_states + ff_output

        if hidden_states.dtype == torch.float16:
            clamp_value = torch.finfo(hidden_states.dtype).max - 1000
            hidden_states = torch.clamp(hidden_states, min=-clamp_value, max=clamp_value)

        return hidden_states


class T5Block(nn.Module):
    def __init__(self, config: T5Config, has_relative_attention_bias: bool = False, prefix: str = ""):
        super().__init__()
        self.layer = nn.ModuleList(
            [
                T5LayerSelfAttention(config, has_relative_attention_bias, prefix=f"{prefix}.layer.0"),
                T5LayerFF(config, prefix=f"{prefix}.layer.1"),
            ]
        )

    def forward(
        self,
        hidden_states: torch.Tensor,
        mask: torch.Tensor | None = None,
        position_bias: torch.Tensor | None = None,
    ) -> tuple[torch.Tensor, torch.Tensor]:
        hidden_states, position_bias = self.layer[0](hidden_states, mask=mask, position_bias=position_bias)
        hidden_states = self.layer[1](hidden_states)
        return hidden_states, position_bias


class T5Stack(nn.Module):
    def __init__(self, config: T5Config, shared: nn.Embedding, prefix: str = ""):
        super().__init__()
        self.embed_tokens = shared
        self.block = nn.ModuleList(
            [
                T5Block(config, has_relative_attention_bias=(i == 0), prefix=f"{prefix}.block.{i}")
                for i in range(config.num_layers)
            ]
        )
        self.final_layer_norm = RMSNorm(config.d_model, eps=config.layer_norm_epsilon)

    def forward(
        self,
        input_ids: torch.Tensor,
        attention_mask: torch.Tensor | None = None,
    ) -> torch.Tensor:
        hidden_states = self.embed_tokens(input_ids)

        # Build attention mask: (batch, seq) -> (batch, 1, 1, seq)
        if attention_mask is not None:
            extended_mask = attention_mask[:, None, None, :].to(dtype=hidden_states.dtype)
            extended_mask = (1.0 - extended_mask) * torch.finfo(hidden_states.dtype).min
        else:
            extended_mask = None

        position_bias = None
        for block in self.block:
            hidden_states, position_bias = block(
                hidden_states,
                mask=extended_mask,
                position_bias=position_bias,
            )

        hidden_states = self.final_layer_norm(hidden_states)
        return hidden_states


class T5EncoderModel(nn.Module):
    """T5 encoder model applying upstream vLLM layers"""

    def __init__(self, config: T5Config, prefix: str = ""):
        super().__init__()
        self.config = config
        self.prefix = prefix
        self.shared = VocabParallelEmbedding(config.vocab_size, config.d_model)
        self.encoder = T5Stack(config, self.shared, prefix=f"{prefix}.encoder")

    @property
    def dtype(self) -> torch.dtype:
        return next(self.parameters()).dtype

    @property
    def device(self) -> torch.device:
        return next(self.parameters()).device

    def embed_input_ids(self, input_ids: torch.Tensor) -> torch.Tensor:
        return self.shared(input_ids)

    def forward(
        self,
        input_ids: torch.Tensor,
        attention_mask: torch.Tensor | None = None,
    ) -> tuple[torch.Tensor, ...]:
        hidden_states = self.encoder(input_ids, attention_mask=attention_mask)
        return (hidden_states,)

    def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
        stacked_params_mapping = [
            ("qkv_proj", "q", "q"),
            ("qkv_proj", "k", "k"),
            ("qkv_proj", "v", "v"),
            ("wi", "wi_0", 0),
            ("wi", "wi_1", 1),
        ]

        model_prefix = self.prefix

        params_dict = dict(self.named_parameters())
        loaded_params: set[str] = set()

        for name, loaded_weight in weights:
            original_name = name

            if model_prefix and name.startswith(model_prefix + "."):
                name = name[len(model_prefix) + 1 :]

            lookup_name = name

            matched = False
            for param_name, weight_name, shard_id in stacked_params_mapping:
                if f".{weight_name}." not in name:
                    continue
                lookup_name = name.replace(f".{weight_name}.", f".{param_name}.", 1)
                if lookup_name in params_dict:
                    param = params_dict[lookup_name]
                    weight_loader = param.weight_loader
                    weight_loader(param, loaded_weight, shard_id)
                    matched = True
                    break

            if not matched:
                if name in params_dict:
                    param = params_dict[name]
                    weight_loader = getattr(param, "weight_loader", default_weight_loader)
                    weight_loader(param, loaded_weight)
                    matched = True

            is_embed = "encoder.embed_tokens" in lookup_name
            is_shared = lookup_name.startswith("shared.") or ".shared." in lookup_name
            target_name = None

            if is_embed or is_shared:
                if is_embed:
                    target_name = lookup_name.replace("encoder.embed_tokens", "shared")
                else:
                    target_name = lookup_name.replace("shared.", "encoder.embed_tokens.", 1)

                if not matched and target_name in params_dict:
                    weight_loader = getattr(params_dict[target_name], "weight_loader", default_weight_loader)
                    weight_loader(params_dict[target_name], loaded_weight)
                    loaded_params.add(target_name)
                    matched = True

            if not matched:
                continue

            if target_name is not None and target_name in params_dict and target_name not in loaded_params:
                if target_name != lookup_name:
                    weight_loader = getattr(params_dict[target_name], "weight_loader", default_weight_loader)
                    weight_loader(params_dict[target_name], loaded_weight)
                    loaded_params.add(target_name)

            loaded_params.add(original_name)
            loaded_params.add(lookup_name)

        return loaded_params
