# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements.  See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership.  The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License.  You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied.  See the License for the
# specific language governing permissions and limitations
# under the License.
"""Basic tests for a Disco nvshmem support"""

# pylint: disable=missing-docstring
import multiprocessing
import os
import shutil
import socket
import subprocess
import sys
import tempfile
import threading

import numpy as np
import pytest
from tvm_ffi import Shape

import tvm
import tvm.testing
from tvm.runtime import disco as di
from tvm.script import ir as I
from tvm.script import relax as R
from tvm.script import tirx as T
from tvm.testing import env

if di is None:
    pytest.skip("disco runtime is not available", allow_module_level=True)

pytestmark = [
    pytest.mark.gpu,
    pytest.mark.skipif(not env.has_nvshmem(), reason="need nvshmem"),
]


_SOCKET_SESSION_TESTER = None


def _get_free_port():
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.bind(("", 0))
    port = sock.getsockname()[1]
    sock.close()
    return port


class SocketSessionTester:
    """Run a disco SocketSession with one local node and remote nodes.

    Each remote node is a `tvm.exec.disco_remote_socket_session` subprocess
    launched with the current Python interpreter.
    """

    def __init__(self, num_workers, num_nodes=2, num_groups=1):
        # Initialize the attributes used by __del__ first, so that teardown is
        # safe even when __init__ raises below.
        self.sess = None
        self.remote_nodes = []
        assert num_workers % num_nodes == 0
        num_workers_per_node = num_workers // num_nodes
        server_host = "localhost"
        server_port = _get_free_port()
        server_exc = []

        def start_server():
            try:
                self.sess = di.SocketSession(
                    num_nodes, num_workers_per_node, num_groups, server_host, server_port
                )
            except Exception as exc:  # pylint: disable=broad-except
                server_exc.append(exc)

        thread = threading.Thread(target=start_server)
        thread.start()

        cmd = "tvm.exec.disco_remote_socket_session"
        for _i in range(num_nodes - 1):
            self.remote_nodes.append(
                subprocess.Popen(
                    [
                        sys.executable,
                        "-m",
                        cmd,
                        server_host,
                        str(server_port),
                        str(num_workers_per_node),
                    ],
                    stdout=sys.stdout,
                    stderr=sys.stderr,
                )
            )

        thread.join()
        if server_exc:
            raise server_exc[0]

    # Bound at class creation: module globals may already be cleared when
    # __del__ runs during interpreter shutdown.
    _TIMEOUT_EXPIRED = subprocess.TimeoutExpired

    def __del__(self):
        try:
            # Shut down the session first so remote nodes can exit gracefully.
            if self.sess is not None:
                self.sess.shutdown()
        finally:
            for node in self.remote_nodes:
                try:
                    node.wait(timeout=10)
                except self._TIMEOUT_EXPIRED:
                    node.kill()
                    node.wait()


def create_socket_session(num_workers):
    """Create a socket session backed by one local and one remote node.

    The tester is kept alive in a module-level global so that the session
    survives until the next call (or interpreter exit) replaces it.
    """
    global _SOCKET_SESSION_TESTER
    # Rebind (not `del`) so the global stays defined if the constructor raises.
    _SOCKET_SESSION_TESTER = None
    _SOCKET_SESSION_TESTER = SocketSessionTester(num_workers)
    assert _SOCKET_SESSION_TESTER.sess is not None
    return _SOCKET_SESSION_TESTER.sess


_all_session_kinds = [di.ProcessSession, create_socket_session]
_all_num_workers = [2, 4]

_SUBPROCESS_TIMEOUT_SEC = 600


def _run_in_fresh_process(target, *args):
    """Run a test body in a freshly spawned process.

    After the first call to `nvshmem_init`, a subsequent call to `nvshmem_init`
    or `nvshmem_init_thread` in the same program results in undefined behavior,
    and worker-0 of a Disco session lives in the calling process. So each test
    body must run in its own process. The 'spawn' start method avoids
    inheriting CUDA state from this process.
    """

    def run_and_check():
        proc = multiprocessing.get_context("spawn").Process(target=target, args=args)
        proc.start()
        proc.join(timeout=_SUBPROCESS_TIMEOUT_SEC)
        if proc.is_alive():
            proc.kill()
            proc.join()
            pytest.fail(
                f"{target.__name__}{args} timed out after {_SUBPROCESS_TIMEOUT_SEC} seconds"
            )
        assert proc.exitcode == 0, f"{target.__name__}{args} failed with exit code {proc.exitcode}"

    tvm.testing.run_with_gpu_lock(run_and_check)


def _require_cuda_devices(num_workers):
    # Each nvshmem worker binds its own CUDA device (cudaSetDevice(worker_id)).
    if not all(tvm.cuda(i).exist for i in range(num_workers)):
        pytest.skip(f"Requires {num_workers} CUDA devices")


def _init_finalize(session_kind, num_workers):
    sess = session_kind(num_workers=num_workers)
    f_init_nvshmem_uid = tvm.get_global_func("runtime.disco.nvshmem.init_nvshmem_uid")
    uid = f_init_nvshmem_uid()
    init_dfunc = sess.get_global_func("runtime.disco.nvshmem.init_nvshmem")
    init_dfunc(uid, num_workers, 0)
    sess.sync_worker_0()
    finalize_dfunc = sess.get_global_func("runtime.disco.nvshmem.finalize_nvshmem")
    finalize_dfunc()
    sess.sync_worker_0()


def _empty(session_kind, num_workers):
    device = tvm.cuda()
    sess = session_kind(num_workers=num_workers)
    f_init_nvshmem_uid = tvm.get_global_func("runtime.disco.nvshmem.init_nvshmem_uid")
    uid = f_init_nvshmem_uid()
    init_dfunc = sess.get_global_func("runtime.disco.nvshmem.init_nvshmem")
    init_dfunc(uid, num_workers, 0)
    sess.sync_worker_0()
    empty_dfunc = sess.get_global_func("runtime.disco.nvshmem.empty")
    _a = empty_dfunc(Shape((32, 64)), "float32", device)
    _b = empty_dfunc(Shape((64, 32)), "float32", device)
    sess.sync_worker_0()
    finalize_dfunc = sess.get_global_func("runtime.disco.nvshmem.finalize_nvshmem")
    finalize_dfunc()
    sess.sync_worker_0()


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("num_workers", _all_num_workers)
def test_nvshmem_init_finalize(session_kind, num_workers: int):
    _require_cuda_devices(num_workers)
    _run_in_fresh_process(_init_finalize, session_kind, num_workers)


@pytest.mark.parametrize("session_kind", _all_session_kinds)
@pytest.mark.parametrize("num_workers", _all_num_workers)
def test_nvshmem_empty(session_kind, num_workers: int):
    _require_cuda_devices(num_workers)
    _run_in_fresh_process(_empty, session_kind, num_workers)


def _compile():
    num_workers = 2
    sess = di.ProcessSession(num_workers=num_workers)

    f_init_nvshmem_uid = tvm.get_global_func("runtime.disco.nvshmem.init_nvshmem_uid")
    uid = f_init_nvshmem_uid()
    init_dfunc = sess.get_global_func("runtime.disco.nvshmem.init_nvshmem")
    init_dfunc(uid, num_workers, 0)
    sess.sync_worker_0()

    @T.prim_func(s_tir=True)
    def main(A: T.Buffer((8, 16), "float32"), B: T.Buffer((16, 8), "float32")):
        for i in T.thread_binding(T.int64(8), thread="threadIdx.y"):
            for j in T.thread_binding(T.int64(16), thread="threadIdx.x"):
                with T.sblock("T_transpose"):
                    v0 = T.axis.spatial(T.int64(8), i)
                    v1 = T.axis.spatial(T.int64(16), j)
                    T.reads(A[v0, v1])
                    T.writes(B[v1, v0])
                    B[v1, v0] = A[v0, v1]

    tmpdir = tempfile.mkdtemp()
    try:
        path = tmpdir + "/test.so"
        A_np = np.arange(8 * 16).astype("float32").reshape([8, 16])
        B_np = np.zeros((16, 8), dtype="float32")
        A_array = sess.empty(A_np.shape, "float32")
        B_array = sess.empty(B_np.shape, "float32")
        A_array.debug_copy_from(0, A_np)

        target = tvm.target.Target("cuda")
        tvm.compile(main, target=target).export_library(path)
        mod = sess.load_vm_module(path)
        mod["main"](A_array, B_array)

        B_res = B_array.debug_get_from_remote(0).numpy()
        np.testing.assert_equal(B_res, A_np.T)

        # sync all workers to make sure the temporary files are cleaned up after all workers
        # finish the execution
        sess._sync_all()

        finalize_dfunc = sess.get_global_func("runtime.disco.nvshmem.finalize_nvshmem")
        finalize_dfunc()
        sess.sync_worker_0()
    finally:
        sess.shutdown()
        shutil.rmtree(tmpdir, ignore_errors=True)


def test_nvshmem_compile():
    _require_cuda_devices(2)
    _run_in_fresh_process(_compile)


NVSHMEM_QUERY_KERNEL_SOURCE = """
#include <nvshmem.h>

extern "C" __global__ void nvshmem_query_kernel(int* my_pe_out, int* n_pes_out) {
    my_pe_out[0] = nvshmem_my_pe();
    n_pes_out[0] = nvshmem_n_pes();
}
"""


def _kernel_compile(compile_mode):
    """Compile and run a kernel that calls NVSHMEM functions.

    Runs in a fresh process, so setting the env var is safe.
    """
    os.environ["TVM_CUDA_COMPILE_MODE"] = compile_mode

    num_workers = 2
    sess = di.ProcessSession(num_workers=num_workers)

    f_init_nvshmem_uid = tvm.get_global_func("runtime.disco.nvshmem.init_nvshmem_uid")
    uid = f_init_nvshmem_uid()
    init_dfunc = sess.get_global_func("runtime.disco.nvshmem.init_nvshmem")
    init_dfunc(uid, num_workers, 0)
    sess.sync_worker_0()

    try:

        @I.ir_module(s_tir=True)
        class NvshmemQueryModule:
            @T.prim_func(s_tir=True)
            def query_pe(
                my_pe_out: T.Buffer((1,), "int32"),
                n_pes_out: T.Buffer((1,), "int32"),
            ):
                with T.sblock("root"):
                    T.reads()
                    T.writes(my_pe_out[0:1], n_pes_out[0:1])
                    T.call_kernel(
                        NVSHMEM_QUERY_KERNEL_SOURCE,
                        ((1,), (1,)),  # grid=(1,), block=(1,)
                        my_pe_out.data,
                        n_pes_out.data,
                        kernel_name="nvshmem_query_kernel",
                    )

            @R.function
            def main() -> R.Tuple(R.Tensor((1,), "int32"), R.Tensor((1,), "int32")):
                cls = NvshmemQueryModule
                with R.dataflow():
                    my_pe = R.call_tir(
                        cls.query_pe,
                        (),
                        out_ty=[
                            R.Tensor((1,), "int32"),
                            R.Tensor((1,), "int32"),
                        ],
                    )
                    R.output(my_pe)
                return my_pe

        tmpdir = tempfile.mkdtemp()
        try:
            path = tmpdir + "/test_nvshmem_kernel.so"

            target = tvm.target.Target("cuda")
            tvm.compile(NvshmemQueryModule, target=target).export_library(path)
            mod = sess.load_vm_module(path)
            result = mod["main"]()

            # Verify results from each worker
            for worker_id in range(num_workers):
                my_pe_result, n_pes_result = result.debug_get_from_remote(worker_id)
                my_pe_val = my_pe_result.numpy()[0]
                n_pes_val = n_pes_result.numpy()[0]
                assert my_pe_val == worker_id, (
                    f"Worker {worker_id} reported my_pe={my_pe_val}, expected {worker_id}"
                )
                assert n_pes_val == num_workers, (
                    f"Worker {worker_id} reported n_pes={n_pes_val}, expected {num_workers}"
                )

            # Sync all workers before cleanup
            sess._sync_all()

            finalize_dfunc = sess.get_global_func("runtime.disco.nvshmem.finalize_nvshmem")
            finalize_dfunc()
            sess.sync_worker_0()
        finally:
            shutil.rmtree(tmpdir, ignore_errors=True)
    finally:
        sess.shutdown()


def test_nvshmem_kernel_compile_nvcc():
    """Test NVSHMEM kernel compilation with nvcc."""
    _require_cuda_devices(2)
    _run_in_fresh_process(_kernel_compile, "nvcc")


def test_nvshmem_kernel_compile_nvrtc():
    """Test NVSHMEM kernel compilation with nvrtc."""
    _require_cuda_devices(2)
    try:
        from cuda.bindings import nvrtc  # noqa: F401
    except ImportError:
        pytest.skip("cuda-python not available, skipping nvrtc test")

    _run_in_fresh_process(_kernel_compile, "nvrtc")


if __name__ == "__main__":
    tvm.testing.main()
