# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
"""Utility helpers for KV cache manipulation, TP routing, and merge/slice."""

from __future__ import annotations

import os
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any

import torch
from vllm.distributed.parallel_state import (
    get_tensor_model_parallel_rank,
    get_tensor_model_parallel_world_size,
)
from vllm.logger import init_logger

from .initialization import KV_RANK_PORT_STRIDE, KV_REPLICA_PORT_STRIDE, KV_TRANSFER_PORT_OFFSET

logger = init_logger(__name__)

LayerKV = torch.Tensor | tuple[torch.Tensor, torch.Tensor]


# ------------------------------------------------------------------ #
#  TP Topology
# ------------------------------------------------------------------ #


@dataclass(frozen=True)
class KVTPTopology:
    """Immutable descriptor for a KV-transfer parallel mapping.

    Captures sender/receiver parallel sizes and the local rank within
    that parallel dimension.  Works for any divisible parallel dimension
    (TP, SP, Ring Attention).
    """

    source_tp_size: int
    target_tp_size: int
    local_rank: int

    def __post_init__(self) -> None:
        if self.source_tp_size <= 0 or self.target_tp_size <= 0:
            raise ValueError(
                f"Parallel sizes must be positive: "
                f"source_tp_size={self.source_tp_size}, target_tp_size={self.target_tp_size}"
            )
        if self.local_rank < 0:
            raise ValueError(f"local_rank must be non-negative, got {self.local_rank}")

    @property
    def is_heterogeneous(self) -> bool:
        return self.source_tp_size != self.target_tp_size

    @property
    def ratio(self) -> int:
        """Larger parallel size divided by smaller. Always >= 1."""
        return max(self.source_tp_size, self.target_tp_size) // min(self.source_tp_size, self.target_tp_size)


# ------------------------------------------------------------------ #
#  Runtime TP detection
# ------------------------------------------------------------------ #


def get_local_tp_rank() -> int:
    """Return the TP-local rank of this worker process.

    Uses ``get_tensor_model_parallel_rank()`` which returns the rank
    within the TP group only, not the stage-global rank.
    """
    try:
        return get_tensor_model_parallel_rank()
    except Exception:
        logger.debug("TP parallel state not initialized, falling back to LOCAL_RANK env", exc_info=True)
    try:
        return int(os.environ.get("LOCAL_RANK", "0"))
    except (ValueError, TypeError):
        return 0


def get_tp_world_size() -> int:
    """Return the TP world size (tensor-parallel dimension only).

    Uses ``get_tensor_model_parallel_world_size()`` so that
    cfg_parallel, SP, PP etc. are not included in the count.
    """
    try:
        return get_tensor_model_parallel_world_size()
    except Exception:
        logger.debug("TP parallel state not initialized, defaulting world_size=1", exc_info=True)
    return 1


def get_omni_replica_id() -> int:
    """Return the Omni replica id for this worker process."""
    try:
        replica_id = int(os.environ.get("VLLM_OMNI_REPLICA_ID", "0"))
    except (ValueError, TypeError):
        return 0
    return max(replica_id, 0)


# ------------------------------------------------------------------ #
#  ZMQ port computation
# ------------------------------------------------------------------ #


def kv_zmq_port(
    base_port: int,
    from_stage: int,
    local_rank: int = 0,
    replica_id: int | None = None,
) -> int:
    """Compute the ZMQ port for a KV-transfer connector.

    Each Omni replica and TP rank gets its own port so multi-replica or
    TP > 1 deployments do not cause ``EADDRINUSE`` when multiple sender
    workers bind on the same host. The formula is backward-compatible:
    replica 0 / rank 0 produces the previous ``base + OFFSET + stage`` port.

    """
    replica = get_omni_replica_id() if replica_id is None else max(int(replica_id), 0)
    return (
        base_port
        + KV_TRANSFER_PORT_OFFSET
        + replica * KV_REPLICA_PORT_STRIDE
        + local_rank * KV_RANK_PORT_STRIDE
        + from_stage
    )


# ------------------------------------------------------------------ #
#  TP topology validation and rank routing
# ------------------------------------------------------------------ #


def validate_kv_tp_topology(topo: KVTPTopology) -> None:
    """Reject heterogeneous TP mappings that cannot be routed losslessly."""
    larger = max(topo.source_tp_size, topo.target_tp_size)
    smaller = min(topo.source_tp_size, topo.target_tp_size)
    if larger % smaller != 0:
        raise ValueError(
            f"KV TP mapping must be divisible: "
            f"source_tp_size={topo.source_tp_size}, "
            f"target_tp_size={topo.target_tp_size}"
        )


def get_kv_target_ranks(topo: KVTPTopology) -> list[int]:
    """Which remote ranks this local rank sends KV shards to (send side)."""
    validate_kv_tp_topology(topo)
    if topo.source_tp_size == topo.target_tp_size:
        return [topo.local_rank]
    if topo.source_tp_size > topo.target_tp_size:
        return [topo.local_rank // (topo.source_tp_size // topo.target_tp_size)]
    ratio = topo.target_tp_size // topo.source_tp_size
    return [topo.local_rank * ratio + i for i in range(ratio)]


def get_kv_source_ranks(topo: KVTPTopology) -> list[int]:
    """Which remote ranks this local rank receives KV shards from (recv side)."""
    validate_kv_tp_topology(topo)
    if topo.source_tp_size == topo.target_tp_size:
        return [topo.local_rank]
    if topo.source_tp_size > topo.target_tp_size:
        ratio = topo.source_tp_size // topo.target_tp_size
        return [topo.local_rank * ratio + i for i in range(ratio)]
    return [topo.local_rank // (topo.target_tp_size // topo.source_tp_size)]


# ------------------------------------------------------------------ #
#  Rank-aware connector key building
# ------------------------------------------------------------------ #


def get_kv_connector_key(
    req_id: str,
    from_stage: int | str,
    chunk_id: int,
    from_rank: int,
    to_rank: int,
) -> str:
    """Build connector key that includes rank info for KV transfers.

    Format matches PR #2677: ``{req_id}_{from_stage}_{chunk_id}_{from_rank}_{to_rank}``
    """
    return f"{req_id}_{from_stage}_{chunk_id}_{from_rank}_{to_rank}"


def build_rank_aware_send_keys(
    request_id: str,
    from_stage: str,
    to_stage: str,
    topo: KVTPTopology,
    hook: Callable | None = None,
) -> list[str]:
    """Build send-side connector keys, checking injectable hook first."""
    if callable(hook):
        keys = list(hook(request_id, from_stage, to_stage))
        if keys:
            return keys
    if topo.source_tp_size <= 1 and topo.target_tp_size <= 1:
        return [f"omni_{from_stage}_to_{to_stage}_kv_cache_{request_id}"]
    target_ranks = get_kv_target_ranks(topo)
    return [get_kv_connector_key(request_id, from_stage, 0, topo.local_rank, r) for r in target_ranks]


def build_rank_aware_recv_keys(
    request_id: str,
    from_stage: str,
    to_stage: str,
    topo: KVTPTopology,
    hook: Callable | None = None,
) -> list[tuple[str, int | None]]:
    """Build recv-side connector keys with sender rank info.

    Returns a list of ``(key, from_rank)`` tuples.  ``from_rank`` is
    ``None`` when TP <= 1 (single sender, no per-rank routing needed).
    For TP > 1, ``from_rank`` identifies which sender rank owns the
    key so that the connector can route metadata queries to the
    correct endpoint.
    """
    if callable(hook):
        raw = list(hook(request_id, from_stage, to_stage))
        if raw:
            if isinstance(raw[0], tuple):
                return raw
            # Hook returned plain strings (e.g. OmniConnectorModelRunnerMixin.
            # get_rank_aware_kv_keys). Reconstruct from_rank from topology so
            # Mooncake connector can route metadata queries to the correct
            # sender endpoint in heterogeneous TP.
            # TODO: have the mixin return (key, from_rank) tuples directly
            # to avoid this indirect reconstruction.
            source_ranks = get_kv_source_ranks(topo)
            if len(raw) == len(source_ranks):
                return list(zip(raw, source_ranks))
            return [(k, None) for k in raw]
    if topo.source_tp_size <= 1 and topo.target_tp_size <= 1:
        return [(f"omni_{from_stage}_to_{to_stage}_kv_cache_{request_id}", None)]
    source_ranks = get_kv_source_ranks(topo)
    return [(get_kv_connector_key(request_id, from_stage, 0, r, topo.local_rank), r) for r in source_ranks]


# ------------------------------------------------------------------ #
#  KV tensor head slicing (heterogeneous TP)
# ------------------------------------------------------------------ #


def slice_kv_tensor_heads(
    tensor: torch.Tensor | None,
    offset_in_shard: int,
    num_slices: int,
) -> torch.Tensor | None:
    """Slice one KV tensor along its head dimension (dim 1)."""
    if tensor is None:
        return None
    if not isinstance(tensor, torch.Tensor):
        return tensor
    if tensor.dim() < 2:
        raise ValueError(f"Expected KV tensor with a head dimension, got shape={tuple(tensor.shape)}")
    if num_slices <= 0:
        raise ValueError(f"num_slices must be > 0, got {num_slices}")
    if not (0 <= offset_in_shard < num_slices):
        raise ValueError(f"offset_in_shard must be in [0, {num_slices}), got {offset_in_shard}")

    heads_in_shard = tensor.shape[1]
    if heads_in_shard % num_slices != 0:
        raise ValueError(
            "KV head count must be divisible for heterogeneous TP slicing: "
            f"heads_in_shard={heads_in_shard}, num_slices={num_slices}"
        )

    heads_per_slice = heads_in_shard // num_slices
    start = offset_in_shard * heads_per_slice
    end = start + heads_per_slice
    return tensor[:, start:end, ...].contiguous()


def slice_layer_blocks(
    layer_blocks: dict[str, Any],
    offset_in_shard: int,
    num_slices: int,
) -> dict[str, list[torch.Tensor | None]]:
    """Slice all KV layers for one logical receiver rank."""
    sliced_blocks: dict[str, list[torch.Tensor | None]] = {}
    for cache_name in ("key_cache", "value_cache"):
        cache_list = layer_blocks.get(cache_name, [])
        sliced_blocks[cache_name] = [
            slice_kv_tensor_heads(tensor, offset_in_shard, num_slices) for tensor in cache_list
        ]
    return sliced_blocks


# ------------------------------------------------------------------ #
#  Multi-rank merge and receiver-side slice
# ------------------------------------------------------------------ #


def merge_received_rank_shards(
    payloads: list[dict[str, Any]],
    merger: Callable | None = None,
) -> dict[str, Any] | None:
    """Merge multiple source-rank KV shards for one target rank.

    When *merger* is provided (injectable hook), it is called directly.
    Otherwise the default merges along the head dimension (dim 1).
    """
    if callable(merger):
        return merger(payloads)
    if not payloads:
        return None
    if len(payloads) == 1:
        return payloads[0]

    base_payload = payloads[0]
    if not isinstance(base_payload, dict) or "layer_blocks" not in base_payload:
        return base_payload

    merged: dict[str, Any] = {
        "request_id": base_payload.get("request_id"),
        "block_ids": list(base_payload.get("block_ids", [])),
        "metadata": dict(base_payload.get("metadata", {})),
    }
    merged_layer_blocks: dict[str, list[torch.Tensor | None]] = {}

    for cache_name in ("key_cache", "value_cache"):
        cache_lists = [payload.get("layer_blocks", {}).get(cache_name, []) for payload in payloads]
        num_layers = max((len(cache_list) for cache_list in cache_lists), default=0)
        merged_cache: list[torch.Tensor | None] = []

        for layer_idx in range(num_layers):
            layer_tensors = [
                cache_list[layer_idx]
                for cache_list in cache_lists
                if layer_idx < len(cache_list) and cache_list[layer_idx] is not None
            ]
            if not layer_tensors:
                merged_cache.append(None)
            elif len(layer_tensors) == 1 or not isinstance(layer_tensors[0], torch.Tensor):
                merged_cache.append(layer_tensors[0])
            else:
                merged_cache.append(torch.cat(layer_tensors, dim=1).contiguous())

        merged_layer_blocks[cache_name] = merged_cache

    merged["layer_blocks"] = merged_layer_blocks
    return merged


def slice_received_rank_shard(
    payload: dict[str, Any] | None,
    topo: KVTPTopology,
    slicer: Callable | None = None,
) -> dict[str, Any] | None:
    """Optionally slice a received payload to extract this rank's portion.

    Used when ``to_tp > from_tp``: the sender sent full heads and each
    receiver rank slices out its own subset.
    """
    if callable(slicer):
        return slicer(payload)
    if not payload or topo.target_tp_size <= topo.source_tp_size or "layer_blocks" not in payload:
        return payload

    metadata = payload.get("metadata", {})
    slice_metadata = metadata.get("tp_head_slice") if isinstance(metadata, dict) else None
    if isinstance(slice_metadata, dict) and slice_metadata.get("applied"):
        tagged_rank = slice_metadata.get("target_rank")
        if tagged_rank is not None and tagged_rank != topo.local_rank:
            logger.warning(
                "Received pre-sliced KV payload for unexpected target rank: expected=%s got=%s",
                topo.local_rank,
                tagged_rank,
            )
        return payload

    ratio = topo.target_tp_size // topo.source_tp_size
    offset_in_sender = topo.local_rank % ratio
    updated_metadata = dict(metadata) if isinstance(metadata, dict) else {}
    updated_metadata["tp_head_slice"] = {
        "applied": True,
        "side": "receiver",
        "target_rank": topo.local_rank,
        "from_tp": topo.source_tp_size,
        "to_tp": topo.target_tp_size,
        "offset_in_shard": offset_in_sender,
        "num_slices": ratio,
    }
    return {
        "request_id": payload.get("request_id"),
        "layer_blocks": slice_layer_blocks(payload["layer_blocks"], offset_in_sender, ratio),
        "block_ids": list(payload.get("block_ids", [])),
        "metadata": updated_metadata,
    }


def normalize_layer_kv(
    layer_kv: LayerKV,
    *,
    req_id: str = "",
    layer_idx: int = -1,
) -> tuple[torch.Tensor, torch.Tensor] | None:
    """Normalize one layer KV cache to a ``(key_blocks, value_blocks)`` tuple.

    In vLLM, different attention backends return paged-attention KV blocks
    with different layouts.  For example:

    * **FlashAttention** (``vllm/v1/attention/backends/flash_attn.py``)
      returns shape ``(2, num_blocks, block_size, num_kv_heads, head_size)``
      – the key/value dimension is at **dim 0**.
    * **FlashInfer** (``vllm/v1/attention/backends/flashinfer.py``)
      returns shape ``(num_blocks, 2, block_size, num_kv_heads, head_size)``
      – the key/value dimension is at **dim 1**.

    This utility handles both layouts (and the tuple case) so that
    downstream code can work with any backend.

    Supported layouts:

    * **Stacked tensor** ``[2, num_blocks, block_size, n_heads, head_dim]`` –
      dim-0 selects key / value.
    * **Stacked tensor** ``[num_blocks, 2, block_size, n_heads, head_dim]`` –
      dim-1 selects key / value.
    * **Tuple** ``(key_tensor, value_tensor)`` – returned as-is after
      validation.

    Args:
        layer_kv: The raw KV cache (tensor or tuple) for the layer.
        req_id: Request ID used only for diagnostic log messages.
        layer_idx: Layer index used only for diagnostic log messages.

    Returns:
        ``(key_blocks, value_blocks)`` if *layer_kv* is valid, ``None``
        otherwise.
    """
    if isinstance(layer_kv, torch.Tensor):
        if layer_kv.ndim >= 3 and layer_kv.shape[0] == 2:
            key_blocks = layer_kv[0]
            value_blocks = layer_kv[1]
        elif layer_kv.ndim >= 3 and layer_kv.shape[1] == 2:
            key_blocks = layer_kv[:, 0]
            value_blocks = layer_kv[:, 1]
        else:
            logger.warning(
                f"Layer {layer_idx} for request {req_id} has invalid stacked KV shape: "
                f"expected [2, ...] or [..., 2, ...] at dim 0/1, got {tuple(layer_kv.shape)}"
            )
            return None
    elif isinstance(layer_kv, tuple):
        if len(layer_kv) != 2:
            logger.warning(f"Layer {layer_idx} for request {req_id} has KV pair length {len(layer_kv)} (expected 2)")
            return None
        key_blocks, value_blocks = layer_kv
        if not isinstance(key_blocks, torch.Tensor) or not isinstance(value_blocks, torch.Tensor):
            logger.warning(f"Layer {layer_idx} for request {req_id} has non-tensor KV pair entries")
            return None
    else:
        logger.warning(f"Layer {layer_idx} for request {req_id} has unsupported KV type {type(layer_kv).__name__}")
        return None

    # ensure key/value blocks are at least 2D for block indexing
    if key_blocks.ndim < 2 or value_blocks.ndim < 2:
        logger.warning(
            f"Layer {layer_idx} for request {req_id} has invalid KV block shape: "
            f"got key={tuple(key_blocks.shape)} value={tuple(value_blocks.shape)}"
        )
        return None

    return key_blocks, value_blocks
