from __future__ import annotations

import functools
import re
from pathlib import Path
from typing import TYPE_CHECKING, Callable, Generic, Optional, Tuple, TypeVar

import torch
from pydantic import BaseModel, ConfigDict

_T = TypeVar("_T")
_U = TypeVar("_U")


def _check_equal_lengths(**named_lists: list) -> None:
    lengths: dict[str, int] = {name: len(lst) for name, lst in named_lists.items()}
    unique: set[int] = set(lengths.values())
    if len(unique) > 1:
        details: str = ", ".join(f"{name}={length}" for name, length in lengths.items())
        raise ValueError(f"Length mismatch: {details}")


def auto_descend_dir(directory: Path, label: str) -> Path:
    """If directory has no .pt files but exactly one subdirectory does, descend into it.

    Raises ValueError when the layout is ambiguous (>=2 subdirs with .pt)
    or when no .pt data is found at all.
    """
    if any(directory.glob("*.pt")):
        return directory

    candidates: list[Path] = [
        sub for sub in directory.iterdir() if sub.is_dir() and any(sub.glob("*.pt"))
    ]

    if len(candidates) >= 2:
        names: str = ", ".join(sorted(c.name for c in candidates))
        raise ValueError(
            f"{label}: directory {directory} has no .pt files at top level "
            f"and multiple subdirectories contain data ({names}). "
            f"Please specify the exact subdirectory."
        )

    if len(candidates) == 0:
        raise ValueError(
            f"{label}: no .pt files found in {directory} or any of its subdirectories."
        )

    resolved: Path = candidates[0]

    from sglang.srt.debug_utils.comparator.log_sink import log_sink
    from sglang.srt.debug_utils.comparator.output_types import InfoLog

    log_sink.add(
        InfoLog(
            category="auto_descend",
            message=f"auto-descend {label}: {directory} -> {resolved}",
        )
    )
    return resolved


class _StrictBase(BaseModel):
    model_config = ConfigDict(extra="forbid")


class _FrozenBase(BaseModel):
    model_config = ConfigDict(frozen=True, extra="forbid")


class Pair(_FrozenBase, Generic[_T]):
    x: _T
    y: _T

    def map(self, fn: Callable[[_T], _U]) -> Pair[_U]:
        return Pair(x=fn(self.x), y=fn(self.y))


def argmax_coord(x: torch.Tensor) -> Tuple[int, ...]:
    flat_idx = x.argmax()
    return tuple(idx.item() for idx in torch.unravel_index(flat_idx, x.shape))


def compute_smaller_dtype(
    dtypes: Pair[torch.dtype],
) -> Optional[torch.dtype]:
    info_dict = {
        (torch.float32, torch.bfloat16): torch.bfloat16,
        # ... add more ...
    }
    return info_dict.get((dtypes.x, dtypes.y)) or info_dict.get((dtypes.y, dtypes.x))


def try_unify_shape(x: torch.Tensor, target_shape: torch.Size) -> torch.Tensor:
    x_shape = x.shape
    num_dim_to_remove = len(x_shape) - len(target_shape)
    if (x_shape[num_dim_to_remove:] == target_shape) and all(
        val == 1 for val in x_shape[:num_dim_to_remove]
    ):
        return functools.reduce(lambda a, _: a.squeeze(0), range(num_dim_to_remove), x)

    return x


# Copied from DeepGEMM
def calc_rel_diff(x: torch.Tensor, y: torch.Tensor) -> torch.Tensor:
    x, y = x.double(), y.double()
    denominator = (x * x + y * y).sum()
    sim = 2 * (x * y).sum() / denominator
    return 1 - sim


def calc_per_token_rel_diff(
    x: torch.Tensor, y: torch.Tensor, *, seq_dim: int
) -> torch.Tensor:
    """Cosine-distance-like metric per token position.

    Sums over all dims except seq_dim.
    """
    x, y = x.double(), y.double()
    other_dims: list[int] = [d for d in range(x.dim()) if d != seq_dim]

    if other_dims:
        denominator: torch.Tensor = (x * x + y * y).sum(dim=other_dims)
        sim: torch.Tensor = 2 * (x * y).sum(dim=other_dims) / (denominator + 1e-10)
    else:
        denominator = x * x + y * y
        sim = 2 * (x * y) / (denominator + 1e-10)

    return (1 - sim).float()


if TYPE_CHECKING:
    from sglang.srt.debug_utils.comparator.output_types import SummaryRecord


def compute_exit_code(
    summary: SummaryRecord,
    *,
    allow_skipped_pattern: str,
    skipped_names: list[str],
    allow_failed_pattern: Optional[str],
    failed_names: list[str],
    errored_names: Optional[list[str]] = None,
) -> int:
    if summary.passed == 0:
        return 1

    if errored_names:
        return 1

    if not _is_all_match_pattern(pattern=allow_failed_pattern, strings=failed_names):
        return 1

    if not _is_all_match_pattern(pattern=allow_skipped_pattern, strings=skipped_names):
        return 1

    return 0


def _is_all_match_pattern(*, pattern: Optional[str], strings: list[str]) -> bool:
    if pattern is None:
        return len(strings) == 0
    compiled: re.Pattern[str] = re.compile(pattern)
    return all(compiled.fullmatch(s) for s in strings)
