"""Multi-process / multi-GPU launching utilities (torchrun-based).

Shared `multigpu_launch` helper that both `sglang.test.kernels.utils` and
`sglang.kernels.jit.benchmark.utils` build their domain-specific entry points on
top of (`multigpu_pytest_main`, `multigpu_bench_main`).

When a script that calls one of those wrappers is run with plain `python`, the
launcher relaunches the same file under `torchrun` once for each `N` in
`num_gpus`. When the inner workers run (identified by an env_key being set),
the same launcher calls `inner()` on every rank, silences stdout on non-zero
ranks, and exits with its return code.
"""

from __future__ import annotations

import atexit
import logging
import os
import signal
import subprocess
import sys
from typing import Any, Callable, List, NoReturn, Optional, Sequence

import psutil
import torch

logger = logging.getLogger(__name__)


def register_comm_cleanup(comm: Any) -> None:
    """Register an idempotent shutdown for a custom-AR communicator."""

    def _safe_close() -> None:
        try:
            comm.close()
        except Exception:
            pass
        # Disable both class flavors' early-out paths in __del__/close.
        try:
            comm.disabled = True
        except Exception:
            pass
        # CustomAllReduceV2: drop ``obj`` so close() short-circuits next time.
        try:
            delattr(comm, "obj")
        except Exception:
            pass
        # CustomAllreduce: zero ``_ptr`` so close() short-circuits next time.
        try:
            comm._ptr = 0
        except Exception:
            pass

    atexit.register(_safe_close)


def _kill_pgroup(pgid: int) -> None:
    try:
        os.killpg(pgid, signal.SIGKILL)
    except ProcessLookupError:
        pass


def _kill_descendants(pid: int) -> None:
    """Snapshot every descendant of `pid` *now* and SIGKILL them all.

    Must be called BEFORE the direct child (torchrun) dies -- once it does,
    its workers get reparented to init and we lose them via the process tree.
    """
    try:
        root = psutil.Process(pid)
    except psutil.NoSuchProcess:
        return
    descendants = root.children(recursive=True)
    for proc in descendants:
        try:
            proc.kill()
        except psutil.Error:
            # NoSuchProcess (already gone) or AccessDenied -- nothing to do.
            pass
    psutil.wait_procs(descendants, timeout=5)


def _extract_num_gpus_override(
    argv: list[str],
) -> tuple[list[int] | None, list[str]]:
    """Pop `--num-gpu(s)` flags out of `argv` and return them separately.

    Accepts `--num-gpu N`, `--num-gpu=N`, `--num-gpus ...`, and comma-separated
    lists like `--num-gpu 2,4,8`. May be repeated.
    """
    override: list[int] = []
    remaining: list[str] = []
    i = 0
    while i < len(argv):
        a = argv[i]
        if a in ("--num-gpu", "--num-gpus"):
            if i + 1 >= len(argv):
                raise ValueError(f"missing value for {a} (expected e.g. `{a} 2,4`)")
            override.extend(int(x) for x in argv[i + 1].split(","))
            i += 2
        elif a.startswith("--num-gpu=") or a.startswith("--num-gpus="):
            _, val = a.split("=", 1)
            override.extend(int(x) for x in val.split(","))
            i += 1
        else:
            remaining.append(a)
            i += 1
    return (override if override else None), remaining


def multigpu_launch(
    name: str,
    file: str,
    num_gpus: Sequence[int],
    env_key: str,
    inner: Callable[[], int],
    kind: str,
    pre_launch_fn: Optional[Callable[[List[int]], None]] = None,
    timeout: Optional[int] = None,
) -> NoReturn | None:
    """Shared torchrun-based launcher.

    See module docstring. `name` is the caller's `__name__`; `file` is its
    `__file__`. `env_key` is a unique string per kind (test/benchmark) used to
    detect the inside-torchrun state. `inner` returns an exit code.

    `pre_launch_fn`, if given, runs once in the outer process *before* any
    torchrun child is spawned. It receives the list of world sizes that will
    actually be launched (already filtered against the host's GPU count and
    any ``--num-gpu`` override). Use it for parallel JIT precompilation so the
    on-disk kernel cache is warm by the time the torchrun children import
    their kernels.

    `timeout`, if given, bounds each per-world-size torchrun invocation (in
    seconds). On expiry the child's whole process group is killed and the
    launcher exits non-zero. `None` (the default) waits indefinitely.
    """
    pid_key = env_key + "_PID"
    if env_key in os.environ:
        assert pid_key in os.environ
        if name != "__main__":
            return
        rank = int(os.environ["LOCAL_RANK"])
        if rank != 0:
            sys.stdout = open(os.devnull, "w")
        torch.cuda.set_device(rank)
        return sys.exit(inner())
    assert pid_key not in os.environ
    if name != "__main__":
        return logger.warning(
            f"{file} can not directly run with `pytest`. "
            "Use `python` to invoke it, which will internally relaunch it "
            "under torchrun for each requested number of GPUs."
        )
    num_devices = torch.cuda.device_count()
    override, forwarded_args = _extract_num_gpus_override(sys.argv[1:])
    if override is not None:
        logger.info(f"--num-gpu override: running only with {override}")
        num_gpus = override
        for N in num_gpus:
            if N <= 1 or N > num_devices:
                raise ValueError(
                    f"Invalid number of GPUs requested: {N} "
                    f"(available: {num_devices})"
                )
    os.environ[env_key] = "1"
    os.environ[pid_key] = str(os.getpid())
    os.environ.setdefault("OMP_NUM_THREADS", "1")
    os.environ.setdefault("GLOO_SOCKET_IFNAME", "lo")  # single-machine setup
    # Unbuffered child stdout: when a worker is killed on timeout, pytest's
    # block-buffered progress output is otherwise lost or flushed out of
    # order into the CI log, making it impossible to tell which test hung.
    os.environ.setdefault("PYTHONUNBUFFERED", "1")
    signal.signal(signal.SIGINT, signal.default_int_handler)
    runnable: List[int] = []
    for N in sorted(num_gpus):
        assert N > 1
        if N > num_devices:
            logger.warning(f"Skipping {kind} with {N} GPUs ({num_devices} available)")
            continue
        runnable.append(N)
    if pre_launch_fn is not None and runnable:
        logger.info(f"Running pre-launch hook for world sizes {runnable}")
        pre_launch_fn(runnable)
    for N in runnable:
        logger.info(f"Running {kind} with {N} GPUs")
        cmd = [
            "torchrun",
            "--nproc_per_node",
            str(N),
            "--local-addr",
            "127.0.0.1",
            file,
        ]
        cmd += forwarded_args
        proc = subprocess.Popen(cmd, start_new_session=True)
        pgid = proc.pid
        returncode = -1
        timed_out = False
        try:
            returncode = proc.wait(timeout=timeout)
        except subprocess.TimeoutExpired:
            timed_out = True
        finally:
            _kill_descendants(os.getpid())
            _kill_pgroup(pgid)
            try:
                proc.wait(timeout=5)
            except subprocess.TimeoutExpired:
                pass
        if timed_out:
            logger.error(f"{kind} (nproc={N}) timed out after {timeout} seconds")
            sys.exit(1)
        if returncode != 0:
            logger.error(f"{kind} failed with {N} GPUs (exit {returncode})")
            sys.exit(returncode)
    logger.info(f"All {kind}s passed")
