from __future__ import annotations

import socket
import sys
from dataclasses import dataclass

import pytest
import torch
import torch.nn.functional as F

from sglang.srt.layers.attention.fla.layernorm_gated import (
    _layer_norm_fwd as layer_norm_fwd,
)
from sglang.srt.layers.attention.fla.layernorm_gated import (
    layernorm_fn,
    rms_norm_ref,
)
from sglang.test.ci.ci_register import register_cuda_ci

register_cuda_ci(
    est_time=60,
    suite="stage-b-test-2-gpu-large",
    disabled="Temporarily disabled",
)

# Optional dependency in sglang repo; skip collection cleanly if absent.
custom_all_reduce_utils = pytest.importorskip(
    "sglang.srt.distributed.device_communicators.custom_all_reduce_utils"
)
parallel_state = pytest.importorskip("sglang.srt.distributed.parallel_state")

update_environment_variables = custom_all_reduce_utils.update_environment_variables
init_distributed_environment = parallel_state.init_distributed_environment
initialize_model_parallel = parallel_state.initialize_model_parallel

NUM_GPUS = 2


def _find_free_port() -> int:
    # Avoid hard-coded port collisions when pytest runs tests in parallel.
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
        s.bind(("localhost", 0))
        s.listen(1)
        return int(s.getsockname()[1])


def _skip_if_no_cuda_or_not_enough_gpus(required_gpus: int = NUM_GPUS) -> None:
    if not torch.cuda.is_available():
        pytest.skip("CUDA device not available")
    if torch.cuda.device_count() < required_gpus:
        pytest.skip(f"Need >= {required_gpus} GPUs, got {torch.cuda.device_count()}")


def _skip_if_dtype_unsupported(dtype: torch.dtype) -> None:
    if dtype is torch.bfloat16 and not torch.cuda.is_bf16_supported():
        pytest.skip("bfloat16 not supported on this CUDA device")


def _setup_sglang_distributed(
    local_rank: int,
    world_size: int,
    master_port: int,
    dtype: torch.dtype,
) -> torch.device:
    # Match sglang test style: set per-rank CUDA device + default dtype/device.
    torch.manual_seed(0)
    torch.cuda.manual_seed_all(0)

    device = torch.device(f"cuda:{local_rank}")
    torch.cuda.set_device(device)

    if hasattr(torch, "set_default_device"):
        torch.set_default_device(device)
    if hasattr(torch, "set_default_dtype"):
        torch.set_default_dtype(dtype)

    update_environment_variables(
        {
            "RANK": str(local_rank),
            "LOCAL_RANK": str(local_rank),
            "WORLD_SIZE": str(world_size),
            "MASTER_ADDR": "localhost",
            "MASTER_PORT": str(master_port),
        }
    )

    init_distributed_environment(
        world_size=world_size, rank=local_rank, local_rank=local_rank
    )
    initialize_model_parallel(tensor_model_parallel_size=world_size)

    return device


def layer_norm_ref(
    x: torch.Tensor,
    weight: torch.Tensor,
    bias: torch.Tensor | None,
    z: torch.Tensor | None = None,
    eps: float = 1e-6,
    group_size: int | None = None,
    norm_before_gate: bool = True,
    is_rms_norm: bool = False,
) -> torch.Tensor:
    """Reference implementation for both LayerNorm and RMSNorm (supports optional gate + group norm)."""
    if is_rms_norm:
        return rms_norm_ref(
            x,
            weight,
            bias,
            z=z,
            eps=eps,
            group_size=group_size,
            norm_before_gate=norm_before_gate,
            upcast=True,
        )

    dtype = x.dtype
    x_f = x.float()
    w_f = weight.float()
    b_f = bias.float() if bias is not None else None
    z_f = z.float() if z is not None else None

    if z_f is not None and not norm_before_gate:
        x_f = x_f * F.silu(z_f)

    if group_size is None:
        mean = x_f.mean(dim=-1, keepdim=True)
        var = (x_f - mean).square().mean(dim=-1, keepdim=True)
        rstd = torch.rsqrt(var + eps)
        out = (x_f - mean) * rstd * w_f
        if b_f is not None:
            out = out + b_f
    else:
        hidden = x_f.shape[-1]
        assert hidden % group_size == 0
        ng = hidden // group_size
        xg = x_f.view(*x_f.shape[:-1], ng, group_size)
        mean = xg.mean(dim=-1, keepdim=True)
        var = (xg - mean).square().mean(dim=-1, keepdim=True)
        rstd = torch.rsqrt(var + eps)
        xg = (xg - mean) * rstd
        out = xg.reshape(*x_f.shape[:-1], hidden) * w_f
        if b_f is not None:
            out = out + b_f

    if z_f is not None and norm_before_gate:
        out = out * F.silu(z_f)

    return out.to(dtype)


@dataclass(frozen=True)
class FwdCase:
    name: str
    with_gate: bool
    norm_before_gate: bool
    group_size: int | None
    is_rms_norm: bool


CASES: list[FwdCase] = [
    FwdCase(
        "layernorm",
        with_gate=False,
        norm_before_gate=True,
        group_size=None,
        is_rms_norm=False,
    ),
    FwdCase(
        "rmsnorm",
        with_gate=False,
        norm_before_gate=True,
        group_size=None,
        is_rms_norm=True,
    ),
    FwdCase(
        "layernorm_gate_pre",
        with_gate=True,
        norm_before_gate=True,
        group_size=None,
        is_rms_norm=False,
    ),
    FwdCase(
        "layernorm_gate_post",
        with_gate=True,
        norm_before_gate=False,
        group_size=None,
        is_rms_norm=False,
    ),
    FwdCase(
        "rmsnorm_gate_pre",
        with_gate=True,
        norm_before_gate=True,
        group_size=None,
        is_rms_norm=True,
    ),
    FwdCase(
        "group_layernorm",
        with_gate=False,
        norm_before_gate=True,
        group_size=128,
        is_rms_norm=False,
    ),
    FwdCase(
        "group_rmsnorm",
        with_gate=False,
        norm_before_gate=True,
        group_size=128,
        is_rms_norm=True,
    ),
]


@pytest.mark.parametrize("num_tokens", [128])
@pytest.mark.parametrize("hidden_size", [256])
@pytest.mark.parametrize("dtype", [torch.bfloat16])
@pytest.mark.parametrize("case", CASES, ids=lambda c: c.name)
def test_layernorm_guard_fwd_spawn(
    num_tokens: int,
    hidden_size: int,
    dtype: torch.dtype,
    case: FwdCase,
    device: str = "cuda",
):
    _skip_if_no_cuda_or_not_enough_gpus(NUM_GPUS)
    _skip_if_dtype_unsupported(dtype)

    if case.group_size is not None and hidden_size % case.group_size != 0:
        pytest.skip(
            f"hidden_size {hidden_size} not divisible by group_size {case.group_size}"
        )

    master_port = _find_free_port()
    world_size = NUM_GPUS

    torch.multiprocessing.spawn(
        _layernorm_guard_fwd_worker,
        args=(
            world_size,
            master_port,
            num_tokens,
            hidden_size,
            dtype,
            case,
            device,
        ),
        nprocs=world_size,
        join=True,
    )


def _layernorm_guard_fwd_worker(
    local_rank: int,
    world_size: int,
    master_port: int,
    num_tokens: int,
    hidden_size: int,
    dtype: torch.dtype,
    case: FwdCase,
    device: str,
):
    device = _setup_sglang_distributed(local_rank, world_size, master_port, dtype)

    with torch.inference_mode():
        torch.manual_seed(42 + local_rank)
        torch.cuda.manual_seed_all(42 + local_rank)

        x = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device)
        z = (
            torch.randn(num_tokens, hidden_size, dtype=dtype, device=device)
            if case.with_gate
            else None
        )
        weight = torch.randn(hidden_size, dtype=dtype, device=device)
        bias = (
            None
            if case.is_rms_norm
            else torch.randn(hidden_size, dtype=dtype, device=device)
        )
        eps = 1e-6

        out, mean, rstd = layer_norm_fwd(
            x,
            weight,
            bias,
            eps,
            z=z,
            group_size=case.group_size,
            norm_before_gate=case.norm_before_gate,
            is_rms_norm=case.is_rms_norm,
        )

        ref_out = layer_norm_ref(
            x,
            weight,
            bias,
            z=z,
            eps=eps,
            group_size=case.group_size,
            norm_before_gate=case.norm_before_gate,
            is_rms_norm=case.is_rms_norm,
        )

        assert out.shape == x.shape
        assert out.dtype == x.dtype
        torch.testing.assert_close(out, ref_out, atol=1e-2, rtol=1e-2)

        # mean/rstd shape checks (same spirit as original vLLM tests)
        if case.group_size is None:
            if not case.is_rms_norm:
                assert mean.shape == (num_tokens,)
            assert rstd.shape == (num_tokens,)
        else:
            ngroups = hidden_size // case.group_size
            if not case.is_rms_norm:
                assert mean.shape == (ngroups * num_tokens,)
            assert rstd.shape == (ngroups * num_tokens,)


@pytest.mark.parametrize("dtype", [torch.bfloat16])
def test_layernorm_guard_misc_spawn(dtype: torch.dtype, device: str = "cuda"):
    _skip_if_no_cuda_or_not_enough_gpus(NUM_GPUS)
    _skip_if_dtype_unsupported(dtype)

    master_port = _find_free_port()
    world_size = NUM_GPUS

    torch.multiprocessing.spawn(
        _layernorm_guard_misc_worker,
        args=(world_size, master_port, dtype, device),
        nprocs=world_size,
        join=True,
    )


def _layernorm_guard_misc_worker(
    local_rank: int,
    world_size: int,
    master_port: int,
    dtype: torch.dtype,
    device: str,
):
    device = _setup_sglang_distributed(local_rank, world_size, master_port, dtype)

    with torch.inference_mode():
        torch.manual_seed(123 + local_rank)
        torch.cuda.manual_seed_all(123 + local_rank)

        # 1) rows_per_block-like sizes
        hidden_size = 1024
        weight = torch.randn(hidden_size, dtype=dtype, device=device)
        bias = torch.randn(hidden_size, dtype=dtype, device=device)
        eps = 1e-6
        for num_tokens in [513]:
            x = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device)
            out, _, _ = layer_norm_fwd(x, weight, bias, eps, z=None, is_rms_norm=False)
            ref = layer_norm_ref(x, weight, bias, z=None, eps=eps, is_rms_norm=False)
            torch.testing.assert_close(out, ref, atol=1e-2, rtol=1e-2)

        # 2) strided input (slice then contiguous)
        num_tokens = 128
        x_large = torch.randn(num_tokens, hidden_size * 2, dtype=dtype, device=device)
        x = x_large[:, :hidden_size]
        x_contig = x.contiguous()
        out, _, _ = layer_norm_fwd(
            x_contig, weight, bias, eps, z=None, is_rms_norm=False
        )
        ref = layer_norm_ref(x_contig, weight, bias, z=None, eps=eps, is_rms_norm=False)
        torch.testing.assert_close(out, ref, atol=1e-2, rtol=1e-2)

        # 3) provided output buffer
        num_tokens = 256
        x = torch.randn(num_tokens, hidden_size, dtype=dtype, device=device)
        out_buf = torch.empty_like(x)
        out, _, _ = layer_norm_fwd(
            x, weight, bias, eps, z=None, out=out_buf, is_rms_norm=False
        )
        assert out.data_ptr() == out_buf.data_ptr()
        ref = layer_norm_ref(x, weight, bias, z=None, eps=eps, is_rms_norm=False)
        torch.testing.assert_close(out, ref, atol=1e-2, rtol=1e-2)

        # 4) multidimensional input via autograd fn
        for shape in [(4, 16, 1024)]:
            hs = shape[-1]
            x = torch.randn(*shape, dtype=dtype, device=device)
            w = torch.randn(hs, dtype=dtype, device=device)
            b = torch.randn(hs, dtype=dtype, device=device)
            out = layernorm_fn(x, w, b, z=None, eps=eps)
            ref = layer_norm_ref(x, w, b, z=None, eps=eps, is_rms_norm=False)
            torch.testing.assert_close(out, ref, atol=1e-2, rtol=1e-2)


if __name__ == "__main__":
    sys.exit(pytest.main([__file__]))
