#!/usr/bin/env python3
# Copyright Advanced Micro Devices, Inc., or its affiliates.
# SPDX-License-Identifier: MIT

"""Run rocroller tests in parallel shards.

Usage: run-tests-sharded PROFILE [BUILD_DIR]

Number of shards is auto-detected as ncores/2.
"""

import argparse
import os
import pty
import random
import select
import subprocess
import sys
import time
from dataclasses import dataclass
from pathlib import Path

# Add lib directory to path for rrtest import
sys.path.insert(0, str(Path(__file__).parent / "lib"))
from rrtest import get_test_commands


@dataclass
class ShardInfo:
    """Information about a running test shard."""

    process: subprocess.Popen
    master_fd: int
    shard_tag: str
    shard_index: int
    test_type: str
    executable: str
    output_buffer: str = ""
    start_time: float = 0.0


def get_gpu_arch():
    """Detect GPU architecture (e.g., gfx1201) if possible."""
    try:
        result = subprocess.run(
            ["rocminfo"],
            capture_output=True,
            text=True,
            check=True,
            timeout=5,
        )
        # Look for gfx architecture in output
        for line in result.stdout.splitlines():
            if "Name:" in line and "gfx" in line.lower():
                # Extract gfx architecture (e.g., gfx1201)
                parts = line.lower().split()
                for part in parts:
                    if part.startswith("gfx"):
                        return part.strip()
    except:
        pass
    return None


def get_available_cpus():
    """Get the number of CPUs available, respecting cgroups and affinity."""
    try:
        result = subprocess.run(
            ["nproc"], capture_output=True, text=True, check=True, timeout=1
        )
        return int(result.stdout.strip())
    except:
        pass

    return os.cpu_count() or 4


def get_shuffle_seed():
    """Return one random seed for all shards (gtest and catch2).

    Uses TEST_SHUFFLE_SEED env var if set (for reproducibility);
    otherwise picks a random seed.

    Returns:
        (seed, from_env): seed is an int in [1, 2**31-1]; from_env is True if
        the seed came from TEST_SHUFFLE_SEED.
    """
    master = os.environ.get("TEST_SHUFFLE_SEED")
    if master is not None:
        return int(master), True
    return random.SystemRandom().randint(1, 2**31 - 1), False


def start_shard(
    shard_index,
    base_cmd,
    test_type,
    num_shards,
    build_dir,
    shard_seed=None,
):
    """Start a shard of tests and return process info.

    Args:
        shard_index: Index of this shard (0-based)
        base_cmd: Base command list from get_test_commands (includes executable and filters)
        test_type: Framework name (e.g., "gtest", "catch2", "ctest")
        num_shards: Total number of shards
        build_dir: Build directory path
        shard_seed: Optional pre-computed random seed for this shard (used for gtest/catch2).
    """
    # Extract executable name from command
    executable = base_cmd[0]

    # Generate shard tag prefix from framework name (first letter uppercase)
    test_prefix = test_type[0].upper()
    shard_tag = f"[{test_prefix}{shard_index:02d}]"
    print(f"{shard_tag} Starting shard", flush=True)

    # Set environment variables
    env = os.environ.copy()

    # Parallelization
    env["OPENBLAS_NUM_THREADS"] = "2"
    env["OMP_NUM_THREADS"] = "2"

    # Debug logging for failed kernel generation
    env["AMD_COMGR_REDIRECT_LOGS"] = "stdout"

    # Start with base command from profile and add framework-specific sharding arguments
    cmd = base_cmd.copy()

    if test_type == "gtest":
        if shard_seed is None:
            raise ValueError("shard_seed is required for gtest")
        env["GTEST_TOTAL_SHARDS"] = str(num_shards)
        env["GTEST_SHARD_INDEX"] = str(shard_index)
        cmd.extend(
            [
                "--gtest_shuffle",
                f"--gtest_random_seed={shard_seed}",
                f"--gtest_output=xml:test_report/gtest_shard_{shard_index}.xml",
            ]
        )
    elif test_type == "catch2":
        if shard_seed is None:
            raise ValueError("shard_seed is required for catch2")
        cmd.extend(
            [
                "--order",
                "rand",
                "--shard-count",
                str(num_shards),
                "--shard-index",
                str(shard_index),
                "--rng-seed",
                str(shard_seed),
                "--reporter",
                f"junit::out=test_report/catch2_shard_{shard_index}.xml",
                "--reporter",
                "console::out=-::colour-mode=ansi",
            ]
        )
    elif test_type == "ctest":
        # CTest doesn't have built-in sharding.  Let CTest use threads instead.
        num_threads = 4
        cmd.extend(
            [
                "--parallel",
                str(num_threads),
                "--output-on-failure",
                "--output-junit",
                f"test_report/ctest_shard_{shard_index}.xml",
            ]
        )

    # Log environment variables we set (added or overridden)
    orig_env = os.environ
    set_vars = [
        (k, env[k])
        for k in sorted(env.keys())
        if k not in orig_env or orig_env.get(k) != env[k]
    ]
    if set_vars:
        print(f"{shard_tag} Environment:", flush=True)
        for k, v in set_vars:
            print(f"{shard_tag}   {k}={v}", flush=True)

    # Log the command being executed
    cmd_str = " ".join(cmd)
    print(f"{shard_tag} Running command in {build_dir}:", flush=True)
    print(f"{shard_tag}   {cmd_str}", flush=True)

    # Use pseudo-TTY to ensure line-buffered output from subprocess
    master_fd, slave_fd = pty.openpty()

    process = subprocess.Popen(
        cmd,
        env=env,
        cwd=build_dir,
        stdout=slave_fd,
        stderr=slave_fd,
        close_fds=True,
    )

    # Close slave fd in parent process
    os.close(slave_fd)

    return ShardInfo(
        process=process,
        master_fd=master_fd,
        shard_tag=shard_tag,
        shard_index=shard_index,
        test_type=test_type,
        executable=executable,
        output_buffer="",
        start_time=time.time(),
    )


def run_shards(shard_infos):
    """Run multiple shards and multiplex their I/O using select.

    Returns: (failed_shards, timing_info)
        failed_shards: list of (shard_index, test_type, exit_code) tuples
        timing_info: list of (shard_index, test_type, executable, elapsed_seconds) tuples
    """
    active_shards = {info.master_fd: info for info in shard_infos}
    failed_shards = []
    timing_info = []

    while active_shards:
        fds = list(active_shards.keys())

        ready, _, _ = select.select(fds, [], [], 0.1)

        for fd in ready:
            info = active_shards[fd]

            try:
                chunk = os.read(fd, 4096).decode("utf-8", errors="replace")
                if chunk:
                    info.output_buffer += chunk

                    # Process complete lines
                    while "\n" in info.output_buffer:
                        line, info.output_buffer = info.output_buffer.split("\n", 1)
                        line = line.rstrip("\r")
                        if line:
                            print(f"{info.shard_tag} {line}", flush=True)
            except OSError:
                # File descriptor closed or error
                pass

        # Check which processes have finished
        finished_fds = []
        for fd, info in active_shards.items():
            if info.process.poll() is not None:
                finished_fds.append(fd)

        # Handle finished processes
        for fd in finished_fds:
            info = active_shards[fd]

            # Read any remaining output
            try:
                while True:
                    chunk = os.read(fd, 4096).decode("utf-8", errors="replace")
                    if not chunk:
                        break
                    info.output_buffer += chunk
            except OSError:
                pass

            # Print any remaining buffered output
            if info.output_buffer:
                for line in info.output_buffer.split("\n"):
                    line = line.rstrip("\r")
                    if line:
                        print(f"{info.shard_tag} {line}", flush=True)

            # Close file descriptor
            os.close(fd)

            # Get return code and elapsed time
            return_code = info.process.wait()
            elapsed_time = time.time() - info.start_time

            # Record timing information
            timing_info.append(
                (info.shard_index, info.test_type, info.executable, elapsed_time)
            )

            if return_code == 0:
                print(
                    f"{info.shard_tag} Completed successfully in {elapsed_time:.1f}s",
                    flush=True,
                )
            else:
                print(
                    f"{info.shard_tag} Failed with exit code {return_code} after {elapsed_time:.1f}s",
                    flush=True,
                )
                failed_shards.append((info.shard_index, info.test_type, return_code))

            # Remove from active shards
            del active_shards[fd]

    return failed_shards, timing_info


def main():
    parser = argparse.ArgumentParser(
        description="Run rocroller tests in parallel shards"
    )
    parser.add_argument(
        "profile",
        help="Test profile to use from test-profiles.yaml (e.g., precheckin, codecov, smoke)",
    )
    parser.add_argument(
        "build_dir",
        nargs="?",
        default="build",
        help="Path to build directory (default: build)",
    )
    parser.add_argument(
        "--dry-run",
        action="store_true",
        help="List tests that would run with the given profile, but don't execute them",
    )

    args = parser.parse_args()

    # Auto-detect number of available CPUs (respecting cgroups) and use half
    num_cores = get_available_cpus()
    num_shards = max(1, num_cores // 2)

    # Cap shards to 8 on gfx1201 due to hanging issues
    gpu_arch = get_gpu_arch()
    if gpu_arch and "gfx1201" in gpu_arch:
        if num_shards > 8:
            print(f"Detected {gpu_arch}, capping shards to 8 (was {num_shards})")
            num_shards = 8

    # Cap shards to 48 maximum
    if num_shards > 48:
        print(f"Capping shards to 48 (was {num_shards})")
        num_shards = 48

    # Resolve build directory
    build_dir = Path(args.build_dir).resolve()
    if not build_dir.exists():
        print(f"ERROR: Build directory does not exist: {build_dir}")
        sys.exit(1)

    # Get test commands from profile using rrtest library
    try:
        commands = get_test_commands(args.profile)
        print(f"Using test profile: {args.profile}")
        print(f"Frameworks: {', '.join(commands.keys())}")
    except Exception as e:
        print(f"ERROR: Failed to load test profile '{args.profile}': {e}")
        sys.exit(1)

    print("=" * 50)
    print("Running sharded tests" + (" [DRY RUN]" if args.dry_run else ""))
    print(f"Build directory: {build_dir}")
    print(f"Test profile: {args.profile}")
    print(f"Available cores: {num_cores}")
    if gpu_arch:
        print(f"GPU architecture: {gpu_arch}")
    if not args.dry_run:
        print(f"Number of shards: {num_shards} (auto-detected)")
        shuffle_seed, from_env = get_shuffle_seed()
        if from_env:
            print(
                f"Shuffle seed: {shuffle_seed} (from TEST_SHUFFLE_SEED, reproducible)"
            )
        else:
            print(f"Shuffle seed: {shuffle_seed} (set TEST_SHUFFLE_SEED to reproduce)")
    if args.dry_run:
        print("Mode: DRY RUN - listing tests only")
    print("=" * 50)

    # Create test report directory
    test_report_dir = build_dir / "test_report"
    test_report_dir.mkdir(exist_ok=True)

    # Handle dry-run mode: list tests instead of executing
    if args.dry_run:
        print("\n=== DRY RUN MODE: Listing tests that would be executed ===\n")

        for framework, cmd_list in commands.items():
            for cmd in cmd_list:
                print(f"--- {framework.upper()} Test Suite ---")
                list_cmd = cmd.copy()

                # Add framework-specific list arguments
                if framework == "gtest":
                    list_cmd.append("--gtest_list_tests")
                elif framework == "catch2":
                    list_cmd.append("--list-tests")
                elif framework == "ctest":
                    list_cmd.extend(["--show-only=json-v1"])

                result = subprocess.run(list_cmd, cwd=build_dir)
                if result.returncode != 0:
                    print(
                        f"ERROR: Failed to list {framework} tests (exit code {result.returncode})"
                    )
                    sys.exit(1)
                print()

        print("=== DRY RUN COMPLETE ===")
        sys.exit(0)

    # Run all shards for each framework
    failed_shards = []
    all_timing_info = []

    # Process each framework in order
    for framework, cmd_list in commands.items():
        for cmd in cmd_list:
            # Determine number of shards based on framework
            # CTest runs tests in parallel internally, so only run 1 "shard"
            framework_num_shards = 1 if framework == "ctest" else num_shards

            print(f"\n--- Running {framework.upper()} shards ---")
            shard_infos = []

            for i in range(framework_num_shards):
                try:
                    seed = shuffle_seed if framework in ("gtest", "catch2") else None
                    info = start_shard(
                        i,
                        cmd,
                        framework,
                        framework_num_shards,
                        build_dir,
                        shard_seed=seed,
                    )
                    shard_infos.append(info)
                except Exception as e:
                    print(
                        f"ERROR: Failed to start {framework} shard {i}: {e}", flush=True
                    )
                    failed_shards.append((i, framework, 1))

            # Run all shards for this framework and wait for completion
            framework_failures, framework_timing = run_shards(shard_infos)
            failed_shards.extend(framework_failures)
            all_timing_info.extend(framework_timing)

    print()

    # Print timing summary
    if all_timing_info:
        print("=" * 50)
        print("TIMING SUMMARY")
        print("=" * 50)

        # Group timings by executable
        executable_timings = {}
        for idx, test_type, executable, elapsed in all_timing_info:
            if executable not in executable_timings:
                executable_timings[executable] = {"framework": test_type, "timings": []}
            executable_timings[executable]["timings"].append((idx, elapsed))

        # Print timings for each executable
        for executable in sorted(executable_timings.keys()):
            data = executable_timings[executable]
            timings = data["timings"]
            framework = data["framework"]
            prefix = framework[0].upper()

            print(f"\n{executable} ({framework}):")
            for idx, elapsed in sorted(timings):
                print(f"  [{prefix}{idx:02d}] {elapsed:6.1f}s")

            total = sum(t for _, t in timings)
            avg = total / len(timings)
            min_time = min(t for _, t in timings)
            max_time = max(t for _, t in timings)
            print(
                f"  Total: {total:.1f}s, Avg: {avg:.1f}s, Min: {min_time:.1f}s, Max: {max_time:.1f}s"
            )

        print("=" * 50)
        print()

    # Report results
    if failed_shards:
        print("FAILED: One or more test shards failed:")
        for shard_index, test_type, exit_code in failed_shards:
            print(f"  - {test_type} shard {shard_index}: exit code {exit_code}")
        sys.exit(1)
    else:
        print("SUCCESS: All test shards completed successfully.")
        print()
        print(f"Test results written to: {test_report_dir}/")

        # List generated XML files
        xml_files = sorted(test_report_dir.glob("*.xml"))
        if xml_files:
            for xml_file in xml_files:
                size = xml_file.stat().st_size
                print(f"  - {xml_file.name} ({size:,} bytes)")

        sys.exit(0)


if __name__ == "__main__":
    main()
