"""Tests for GPU Respect Architecture -- passive monitoring and process classification.

Validates:
- Process classification heuristics (training, rendering, gaming, compute, inference, system)
- GPU availability evaluation (headroom, utilization, blocking processes)
- Our-process exclusion (managed engine doesn't count as "busy")
- Foreign inference respected (never killed or interfered with)
- No-GPU graceful handling (CPU-only machines)
- Apple Silicon unified memory handling
- pynvml integration (mocked)
- nvidia-smi fallback (mocked)
- AMD rocm-smi (mocked)
"""

from __future__ import annotations

from unittest.mock import AsyncMock, MagicMock, mock_open, patch

import pytest

from llmhosts.discovery.gpu_monitor import (
    PROCESS_TYPE_COMPUTE,
    PROCESS_TYPE_GAMING,
    PROCESS_TYPE_INFERENCE_FOREIGN,
    PROCESS_TYPE_INFERENCE_OWN,
    PROCESS_TYPE_RENDERING,
    PROCESS_TYPE_SYSTEM,
    PROCESS_TYPE_TRAINING,
    PROCESS_TYPE_UNKNOWN,
    GPUAvailability,
    GPUMonitor,
    GPUProcess,
    _classify_process,
    _is_our_process,
)

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _make_process(
    pid: int = 1000,
    name: str = "test",
    gpu_memory_mb: int = 100,
    process_type: str = PROCESS_TYPE_UNKNOWN,
) -> GPUProcess:
    return GPUProcess(pid=pid, name=name, gpu_memory_mb=gpu_memory_mb, process_type=process_type)


def _make_availability(
    gpu_index: int = 0,
    total_vram_mb: int = 8192,
    used_vram_mb: int = 0,
    free_vram_mb: int = 8192,
    available: bool = True,
    reason: str = "GPU available for inference",
    processes: list[GPUProcess] | None = None,
    gpu_utilization_pct: float = 0.0,
) -> GPUAvailability:
    return GPUAvailability(
        gpu_index=gpu_index,
        total_vram_mb=total_vram_mb,
        used_vram_mb=used_vram_mb,
        free_vram_mb=free_vram_mb,
        available_for_inference=available,
        reason=reason,
        processes=processes or [],
        gpu_utilization_pct=gpu_utilization_pct,
    )


# ---------------------------------------------------------------------------
# Process classification tests
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestProcessClassification:
    """Verify process classification heuristics for GPU process types."""

    def test_classify_training_deepspeed(self) -> None:
        assert _classify_process("python3", "deepspeed train.py") == PROCESS_TYPE_TRAINING

    def test_classify_training_accelerate(self) -> None:
        assert _classify_process("python", "accelerate launch finetune.py") == PROCESS_TYPE_TRAINING

    def test_classify_training_nccl(self) -> None:
        assert _classify_process("nccl-test", "") == PROCESS_TYPE_TRAINING

    def test_classify_training_pytorch_lightning(self) -> None:
        assert _classify_process("python3", "python3 -m pytorch_lightning.trainer --gpus 4") == PROCESS_TYPE_TRAINING

    def test_classify_training_huggingface_trainer(self) -> None:
        assert _classify_process("python", "transformers trainer --model bert") == PROCESS_TYPE_TRAINING

    def test_classify_training_axolotl(self) -> None:
        assert _classify_process("python3", "axolotl train config.yaml") == PROCESS_TYPE_TRAINING

    def test_classify_training_unsloth(self) -> None:
        assert _classify_process("python3", "unsloth finetune model") == PROCESS_TYPE_TRAINING

    def test_classify_training_lora(self) -> None:
        assert _classify_process("python3", "python train_lora.py --dataset data.json") == PROCESS_TYPE_TRAINING

    def test_classify_rendering_blender(self) -> None:
        assert (
            _classify_process("blender", "/usr/bin/blender -b scene.blend -o output -F PNG") == PROCESS_TYPE_RENDERING
        )

    def test_classify_rendering_davinci_resolve(self) -> None:
        assert _classify_process("resolve", "/opt/resolve/bin/resolve") == PROCESS_TYPE_RENDERING

    def test_classify_rendering_obs(self) -> None:
        assert _classify_process("obs", "") == PROCESS_TYPE_RENDERING

    def test_classify_rendering_ffmpeg(self) -> None:
        assert _classify_process("ffmpeg", "ffmpeg -i input.mp4 -c:v h264_nvenc output.mp4") == PROCESS_TYPE_RENDERING

    def test_classify_rendering_unity(self) -> None:
        assert _classify_process("Unity", "/opt/Unity/Editor/Unity") == PROCESS_TYPE_RENDERING

    def test_classify_rendering_unreal(self) -> None:
        assert _classify_process("UnrealEditor", "") == PROCESS_TYPE_RENDERING

    def test_classify_gaming_steam(self) -> None:
        assert _classify_process("steam", "/usr/bin/steam") == PROCESS_TYPE_GAMING

    def test_classify_gaming_proton(self) -> None:
        assert _classify_process("proton", "/path/to/proton run game.exe") == PROCESS_TYPE_GAMING

    def test_classify_gaming_gamescope(self) -> None:
        assert _classify_process("gamescope", "gamescope -W 1920 -H 1080") == PROCESS_TYPE_GAMING

    def test_classify_compute_hashcat(self) -> None:
        assert _classify_process("hashcat", "hashcat -m 0 -a 0 hash.txt wordlist.txt") == PROCESS_TYPE_COMPUTE

    def test_classify_compute_mining(self) -> None:
        assert _classify_process("xmrig", "") == PROCESS_TYPE_COMPUTE

    def test_classify_compute_folding(self) -> None:
        assert _classify_process("FAHClient", "fah core") == PROCESS_TYPE_COMPUTE

    def test_classify_compute_matlab(self) -> None:
        assert _classify_process("MATLAB", "/usr/local/MATLAB/bin/matlab") == PROCESS_TYPE_COMPUTE

    def test_classify_compute_rapids(self) -> None:
        assert _classify_process("python3", "python3 -c 'import cudf; ...'") == PROCESS_TYPE_COMPUTE

    def test_classify_inference_foreign_ollama(self) -> None:
        """Foreign inference engines are respected -- not killed."""
        assert _classify_process("ollama", "ollama serve") == PROCESS_TYPE_INFERENCE_FOREIGN

    def test_classify_inference_foreign_vllm(self) -> None:
        assert (
            _classify_process("python3", "python3 -m vllm.entrypoints.openai.api_server")
            == PROCESS_TYPE_INFERENCE_FOREIGN
        )

    def test_classify_inference_foreign_llama_cpp(self) -> None:
        assert _classify_process("llama-server", "./llama-server -m model.gguf") == PROCESS_TYPE_INFERENCE_FOREIGN

    def test_classify_inference_foreign_text_gen(self) -> None:
        assert (
            _classify_process("python", "text-generation-launcher --model meta-llama") == PROCESS_TYPE_INFERENCE_FOREIGN
        )

    def test_classify_inference_foreign_lmstudio(self) -> None:
        assert _classify_process("lmstudio", "/opt/lmstudio/lmstudio") == PROCESS_TYPE_INFERENCE_FOREIGN

    def test_classify_inference_foreign_koboldcpp(self) -> None:
        assert _classify_process("koboldcpp", "koboldcpp model.gguf") == PROCESS_TYPE_INFERENCE_FOREIGN

    def test_classify_inference_foreign_python_torch(self) -> None:
        """Python with torch but no training keywords -> inferred as inference."""
        assert _classify_process("python3", "python3 serve.py --model torch_model") == PROCESS_TYPE_INFERENCE_FOREIGN

    def test_classify_system_xorg(self) -> None:
        assert _classify_process("Xorg", "/usr/bin/Xorg :0") == PROCESS_TYPE_SYSTEM

    def test_classify_system_gnome_shell(self) -> None:
        assert _classify_process("gnome-shell", "/usr/bin/gnome-shell") == PROCESS_TYPE_SYSTEM

    def test_classify_system_nvidia_persistenced(self) -> None:
        assert _classify_process("nvidia-persistenced", "") == PROCESS_TYPE_SYSTEM

    def test_classify_system_compositor(self) -> None:
        assert _classify_process("picom", "picom --experimental-backends") == PROCESS_TYPE_SYSTEM

    def test_classify_unknown_generic(self) -> None:
        assert _classify_process("my-app", "/usr/bin/my-app --flag") == PROCESS_TYPE_UNKNOWN

    def test_classify_unknown_empty(self) -> None:
        assert _classify_process("", "") == PROCESS_TYPE_UNKNOWN

    def test_classify_case_insensitive(self) -> None:
        """Classification should be case-insensitive."""
        assert _classify_process("BLENDER", "BLENDER -b scene.blend") == PROCESS_TYPE_RENDERING
        assert _classify_process("DeepSpeed", "") == PROCESS_TYPE_TRAINING


# ---------------------------------------------------------------------------
# Availability evaluation tests
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestAvailabilityEvaluation:
    """Verify GPU availability logic respects user workloads."""

    def test_gpu_free_is_available(self) -> None:
        monitor = GPUMonitor()
        available, reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=[],
        )
        assert available is True
        assert "available" in reason.lower()

    def test_gpu_training_blocks_inference(self) -> None:
        monitor = GPUMonitor()
        processes = [_make_process(name="python3", process_type=PROCESS_TYPE_TRAINING)]
        available, reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=processes,
        )
        assert available is False
        assert "training" in reason.lower()

    def test_gpu_rendering_blocks_inference(self) -> None:
        monitor = GPUMonitor()
        processes = [_make_process(name="blender", process_type=PROCESS_TYPE_RENDERING)]
        available, reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=processes,
        )
        assert available is False
        assert "rendering" in reason.lower()

    def test_gpu_gaming_blocks_inference(self) -> None:
        monitor = GPUMonitor()
        processes = [_make_process(name="steam", process_type=PROCESS_TYPE_GAMING)]
        available, reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=processes,
        )
        assert available is False
        assert "gaming" in reason.lower()

    def test_gpu_compute_blocks_inference(self) -> None:
        monitor = GPUMonitor()
        processes = [_make_process(name="hashcat", process_type=PROCESS_TYPE_COMPUTE)]
        available, reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=processes,
        )
        assert available is False
        assert "compute" in reason.lower()

    def test_foreign_inference_respected(self) -> None:
        """Foreign inference engines are respected -- we don't run alongside them."""
        monitor = GPUMonitor()
        processes = [_make_process(name="llama-server", process_type=PROCESS_TYPE_INFERENCE_FOREIGN)]
        available, reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=processes,
        )
        assert available is False
        assert "respecting" in reason.lower()

    def test_own_inference_excluded(self) -> None:
        """Our own inference engine does NOT block new inference."""
        monitor = GPUMonitor()
        processes = [_make_process(name="ollama", process_type=PROCESS_TYPE_INFERENCE_OWN)]
        available, _reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=processes,
        )
        assert available is True

    def test_system_process_does_not_block(self) -> None:
        """System processes (display, compositor) don't block inference."""
        monitor = GPUMonitor()
        processes = [_make_process(name="Xorg", process_type=PROCESS_TYPE_SYSTEM)]
        available, _reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=processes,
        )
        assert available is True

    def test_unknown_process_does_not_block(self) -> None:
        """Unknown processes don't block (benefit of the doubt -- could be anything)."""
        monitor = GPUMonitor()
        processes = [_make_process(name="mystery-app", process_type=PROCESS_TYPE_UNKNOWN)]
        available, _reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=processes,
        )
        assert available is True

    def test_high_utilization_blocks(self) -> None:
        """GPU utilization above threshold blocks even with free VRAM."""
        monitor = GPUMonitor(utilization_threshold=0.85)
        available, reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.90,
            processes=[],
        )
        assert available is False
        assert "utilization" in reason.lower()

    def test_utilization_at_threshold_does_not_block(self) -> None:
        """Utilization exactly at threshold should not block."""
        monitor = GPUMonitor(utilization_threshold=0.85)
        available, _reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.85,
            processes=[],
        )
        assert available is True

    def test_low_vram_headroom_blocks(self) -> None:
        """Free VRAM below headroom requirement blocks inference."""
        monitor = GPUMonitor(vram_headroom_mb=512)
        available, reason = monitor._evaluate_availability(
            free_vram_mb=256,
            gpu_util_pct=0.1,
            processes=[],
        )
        assert available is False
        assert "headroom" in reason.lower()

    def test_exact_headroom_blocks(self) -> None:
        """Free VRAM exactly equal to headroom blocks (need SOME room for model)."""
        monitor = GPUMonitor(vram_headroom_mb=512)
        available, reason = monitor._evaluate_availability(
            free_vram_mb=512,
            gpu_util_pct=0.1,
            processes=[],
        )
        assert available is False
        assert "headroom" in reason.lower()

    def test_headroom_plus_one_is_available(self) -> None:
        """Free VRAM just above headroom is available."""
        monitor = GPUMonitor(vram_headroom_mb=512)
        available, _reason = monitor._evaluate_availability(
            free_vram_mb=513,
            gpu_util_pct=0.1,
            processes=[],
        )
        assert available is True

    def test_custom_headroom(self) -> None:
        """Custom headroom value is respected."""
        monitor = GPUMonitor(vram_headroom_mb=1024)
        available, _ = monitor._evaluate_availability(
            free_vram_mb=800,
            gpu_util_pct=0.1,
            processes=[],
        )
        assert available is False

    def test_custom_utilization_threshold(self) -> None:
        """Custom utilization threshold is respected."""
        monitor = GPUMonitor(utilization_threshold=0.50)
        available, _ = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.60,
            processes=[],
        )
        assert available is False

    def test_mixed_processes_training_blocks(self) -> None:
        """If ANY blocking process type is present, GPU is not available."""
        monitor = GPUMonitor()
        processes = [
            _make_process(name="Xorg", process_type=PROCESS_TYPE_SYSTEM),
            _make_process(name="python3", process_type=PROCESS_TYPE_TRAINING),
            _make_process(name="ollama", process_type=PROCESS_TYPE_INFERENCE_OWN),
        ]
        available, reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=processes,
        )
        assert available is False
        assert "training" in reason.lower()

    def test_multiple_own_engines_still_available(self) -> None:
        """Multiple own-inference processes should not block."""
        monitor = GPUMonitor()
        processes = [
            _make_process(pid=100, name="ollama", process_type=PROCESS_TYPE_INFERENCE_OWN),
            _make_process(pid=101, name="ollama_llama_server", process_type=PROCESS_TYPE_INFERENCE_OWN),
        ]
        available, _ = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.1,
            processes=processes,
        )
        assert available is True


# ---------------------------------------------------------------------------
# available_vram_for_model tests
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestAvailableVramForModel:
    """Verify VRAM calculation for model loading."""

    def test_available_subtracts_headroom(self) -> None:
        monitor = GPUMonitor(vram_headroom_mb=512)
        avail = _make_availability(free_vram_mb=8192, available=True)
        assert monitor.available_vram_for_model(avail) == 8192 - 512

    def test_unavailable_returns_zero(self) -> None:
        monitor = GPUMonitor()
        avail = _make_availability(available=False, free_vram_mb=8192)
        assert monitor.available_vram_for_model(avail) == 0

    def test_headroom_exceeds_free_returns_zero(self) -> None:
        monitor = GPUMonitor(vram_headroom_mb=1024)
        avail = _make_availability(free_vram_mb=512, available=True)
        assert monitor.available_vram_for_model(avail) == 0


# ---------------------------------------------------------------------------
# is_our_process tests
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestIsOurProcess:
    """Verify our-process detection logic."""

    def test_explicit_pid_match(self) -> None:
        assert _is_our_process(42, {42, 100, 200}) is True

    def test_pid_not_in_set(self) -> None:
        with patch("llmhosts.discovery.gpu_monitor._get_process_name", return_value="unknown"):
            assert _is_our_process(99, {42, 100}) is False

    def test_current_pid_is_ours(self) -> None:
        import os

        assert _is_our_process(os.getpid(), set()) is True

    def test_engine_name_with_our_parent(self) -> None:
        """An engine process whose parent is our PID should be detected as ours."""
        import os

        current_pid = os.getpid()
        fake_status = f"Name:\tollama\nPPid:\t{current_pid}\n"
        with (
            patch("llmhosts.discovery.gpu_monitor._get_process_name", return_value="ollama"),
            patch("builtins.open", mock_open(read_data=fake_status)),
        ):
            assert _is_our_process(12345, set()) is True

    def test_engine_name_with_foreign_parent(self) -> None:
        """An engine process whose parent is NOT us should not be detected as ours."""
        fake_status = "Name:\tollama\nPPid:\t1\n"
        with (
            patch("llmhosts.discovery.gpu_monitor._get_process_name", return_value="ollama"),
            patch("builtins.open", mock_open(read_data=fake_status)),
        ):
            assert _is_our_process(12345, set()) is False


# ---------------------------------------------------------------------------
# GPUMonitor.check_availability with mocked pynvml
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestCheckAvailabilityPynvml:
    """Verify pynvml-based availability checks (mocked)."""

    async def test_single_gpu_available(self) -> None:
        """Single idle GPU should be available."""
        mock_pynvml = MagicMock()
        mock_pynvml.nvmlDeviceGetCount.return_value = 1
        mock_handle = MagicMock()
        mock_pynvml.nvmlDeviceGetHandleByIndex.return_value = mock_handle

        # Memory: 8 GB total, 500 MB used
        mem_info = MagicMock()
        mem_info.total = 8 * 1024 * 1024 * 1024
        mem_info.used = 500 * 1024 * 1024
        mem_info.free = (8 * 1024 - 500) * 1024 * 1024
        mock_pynvml.nvmlDeviceGetMemoryInfo.return_value = mem_info

        util = MagicMock()
        util.gpu = 10  # 10%
        mock_pynvml.nvmlDeviceGetUtilizationRates.return_value = util

        mock_pynvml.nvmlDeviceGetComputeRunningProcesses.return_value = []
        mock_pynvml.nvmlDeviceGetGraphicsRunningProcesses.return_value = []

        monitor = GPUMonitor()
        with (
            patch("llmhosts.discovery.gpu_monitor.pynvml", mock_pynvml),
            patch("llmhosts.discovery.gpu_monitor._pynvml_available", True),
        ):
            results = await monitor.check_availability()

        assert len(results) == 1
        assert results[0].available_for_inference is True
        assert results[0].total_vram_mb == 8192
        assert results[0].gpu_utilization_pct == 10.0

    async def test_gpu_busy_with_training(self) -> None:
        """GPU with a training process should be unavailable."""
        mock_pynvml = MagicMock()
        mock_pynvml.nvmlDeviceGetCount.return_value = 1
        mock_handle = MagicMock()
        mock_pynvml.nvmlDeviceGetHandleByIndex.return_value = mock_handle

        mem_info = MagicMock()
        mem_info.total = 24 * 1024 * 1024 * 1024
        mem_info.used = 20 * 1024 * 1024 * 1024
        mem_info.free = 4 * 1024 * 1024 * 1024
        mock_pynvml.nvmlDeviceGetMemoryInfo.return_value = mem_info

        util = MagicMock()
        util.gpu = 95
        mock_pynvml.nvmlDeviceGetUtilizationRates.return_value = util

        # Training process
        training_proc = MagicMock()
        training_proc.pid = 5000
        training_proc.usedGpuMemory = 18 * 1024 * 1024 * 1024
        mock_pynvml.nvmlDeviceGetComputeRunningProcesses.return_value = [training_proc]
        mock_pynvml.nvmlDeviceGetGraphicsRunningProcesses.return_value = []

        monitor = GPUMonitor()
        with (
            patch("llmhosts.discovery.gpu_monitor.pynvml", mock_pynvml),
            patch("llmhosts.discovery.gpu_monitor._pynvml_available", True),
            patch("llmhosts.discovery.gpu_monitor._get_process_name", return_value="python3"),
            patch(
                "llmhosts.discovery.gpu_monitor._get_process_cmdline",
                return_value="python3 -m accelerate launch train.py",
            ),
        ):
            results = await monitor.check_availability()

        assert len(results) == 1
        assert results[0].available_for_inference is False
        assert "training" in results[0].reason.lower()

    async def test_multiple_gpus(self) -> None:
        """Multi-GPU system: one busy, one free."""
        mock_pynvml = MagicMock()
        mock_pynvml.nvmlDeviceGetCount.return_value = 2

        handles = [MagicMock(), MagicMock()]
        mock_pynvml.nvmlDeviceGetHandleByIndex.side_effect = handles

        # GPU 0: busy (high utilization)
        mem0 = MagicMock()
        mem0.total = 8 * 1024 * 1024 * 1024
        mem0.used = 7 * 1024 * 1024 * 1024
        mem0.free = 1 * 1024 * 1024 * 1024
        util0 = MagicMock()
        util0.gpu = 95

        # GPU 1: free
        mem1 = MagicMock()
        mem1.total = 8 * 1024 * 1024 * 1024
        mem1.used = 200 * 1024 * 1024
        mem1.free = (8 * 1024 - 200) * 1024 * 1024
        util1 = MagicMock()
        util1.gpu = 5

        mock_pynvml.nvmlDeviceGetMemoryInfo.side_effect = [mem0, mem1]
        mock_pynvml.nvmlDeviceGetUtilizationRates.side_effect = [util0, util1]
        mock_pynvml.nvmlDeviceGetComputeRunningProcesses.return_value = []
        mock_pynvml.nvmlDeviceGetGraphicsRunningProcesses.return_value = []

        monitor = GPUMonitor()
        with (
            patch("llmhosts.discovery.gpu_monitor.pynvml", mock_pynvml),
            patch("llmhosts.discovery.gpu_monitor._pynvml_available", True),
        ):
            results = await monitor.check_availability()

        assert len(results) == 2
        # GPU 0 busy (high utilization)
        assert results[0].available_for_inference is False
        # GPU 1 free
        assert results[1].available_for_inference is True


# ---------------------------------------------------------------------------
# nvidia-smi fallback tests
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestCheckAvailabilityNvidiaSmi:
    """Verify nvidia-smi fallback parsing (mocked subprocess)."""

    async def test_nvidia_smi_parses_output(self) -> None:
        """nvidia-smi CSV output should be parsed correctly."""
        gpu_csv = "8192, 500, 7692, 10\n"
        proc_csv = ""  # no processes

        monitor = GPUMonitor()

        async def _mock_subprocess(*args, **kwargs):
            mock = MagicMock()
            args_str = " ".join(str(a) for a in args)
            if "--query-gpu" in args_str:
                mock.communicate = AsyncMock(return_value=(gpu_csv.encode(), b""))
                mock.returncode = 0
            else:
                mock.communicate = AsyncMock(return_value=(proc_csv.encode(), b""))
                mock.returncode = 0
            return mock

        with (
            patch("llmhosts.discovery.gpu_monitor._pynvml_available", False),
            patch(
                "llmhosts.discovery.gpu_monitor.shutil.which",
                side_effect=lambda x: "/usr/bin/nvidia-smi" if x == "nvidia-smi" else None,
            ),
            patch("asyncio.create_subprocess_exec", side_effect=_mock_subprocess),
            patch("asyncio.wait_for", side_effect=_passthrough_wait_for),
        ):
            results = await monitor.check_availability()

        assert len(results) == 1
        assert results[0].total_vram_mb == 8192
        assert results[0].free_vram_mb == 7692
        assert results[0].available_for_inference is True


# ---------------------------------------------------------------------------
# CPU-only (no GPU) tests
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestNoGPU:
    """Verify graceful handling when no GPU is detected."""

    async def test_cpu_only_returns_empty(self) -> None:
        """CPU-only machine should return empty list (inference still possible on CPU)."""
        monitor = GPUMonitor()
        with (
            patch("llmhosts.discovery.gpu_monitor._pynvml_available", False),
            patch("shutil.which", return_value=None),
            patch("platform.system", return_value="Linux"),
        ):
            results = await monitor.check_availability()
        assert results == []

    async def test_no_gpu_tools_graceful(self) -> None:
        """When no GPU tools are available, check_availability returns empty without error."""
        monitor = GPUMonitor()
        with (
            patch("llmhosts.discovery.gpu_monitor._pynvml_available", False),
            patch("shutil.which", return_value=None),
            patch("platform.system", return_value="Linux"),
        ):
            results = await monitor.check_availability()
        assert isinstance(results, list)
        assert len(results) == 0


# ---------------------------------------------------------------------------
# Apple Silicon tests
# ---------------------------------------------------------------------------


async def _passthrough_wait_for(coro, timeout):
    """Helper to replace asyncio.wait_for in tests -- just awaits without timeout."""
    return await coro


@pytest.mark.unit
class TestAppleSilicon:
    """Verify Apple Silicon unified memory handling."""

    async def test_apple_silicon_normal_pressure(self) -> None:
        """Normal memory pressure = available."""
        monitor = GPUMonitor()

        # 16 GB system
        total_bytes = 16 * 1024 * 1024 * 1024
        call_count = 0

        async def _mock_subprocess(*args, **kwargs):
            nonlocal call_count
            mock = MagicMock()
            call_count += 1
            if call_count == 1:
                # First call: sysctl hw.memsize
                mock.communicate = AsyncMock(return_value=(str(total_bytes).encode(), b""))
                mock.returncode = 0
            else:
                # Second call: memory_pressure
                mock.communicate = AsyncMock(return_value=(b"The system has normal memory pressure.", b""))
                mock.returncode = 0
            return mock

        with (
            patch("platform.system", return_value="Darwin"),
            patch("shutil.which", return_value="/usr/bin/memory_pressure"),
            patch("asyncio.create_subprocess_exec", side_effect=_mock_subprocess),
            patch("asyncio.wait_for", side_effect=_passthrough_wait_for),
        ):
            results = await monitor.check_availability()

        assert len(results) == 1
        assert results[0].available_for_inference is True
        # 16 GB - 4 GB OS overhead = 12 GB = 12288 MB
        assert results[0].total_vram_mb == 12288

    async def test_apple_silicon_critical_pressure(self) -> None:
        """Critical memory pressure = yield to user."""
        monitor = GPUMonitor()
        total_bytes = 16 * 1024 * 1024 * 1024
        call_count = 0

        async def _mock_subprocess(*args, **kwargs):
            nonlocal call_count
            mock = MagicMock()
            call_count += 1
            if call_count == 1:
                mock.communicate = AsyncMock(return_value=(str(total_bytes).encode(), b""))
                mock.returncode = 0
            else:
                mock.communicate = AsyncMock(return_value=(b"The system is under critical memory pressure.", b""))
                mock.returncode = 0
            return mock

        with (
            patch("platform.system", return_value="Darwin"),
            patch("shutil.which", return_value="/usr/bin/memory_pressure"),
            patch("asyncio.create_subprocess_exec", side_effect=_mock_subprocess),
            patch("asyncio.wait_for", side_effect=_passthrough_wait_for),
        ):
            results = await monitor.check_availability()

        assert len(results) == 1
        assert results[0].available_for_inference is False
        assert "critical" in results[0].reason.lower()

    async def test_apple_silicon_warn_pressure(self) -> None:
        """Warning memory pressure = yield to user."""
        monitor = GPUMonitor()
        total_bytes = 32 * 1024 * 1024 * 1024
        call_count = 0

        async def _mock_subprocess(*args, **kwargs):
            nonlocal call_count
            mock = MagicMock()
            call_count += 1
            if call_count == 1:
                mock.communicate = AsyncMock(return_value=(str(total_bytes).encode(), b""))
                mock.returncode = 0
            else:
                mock.communicate = AsyncMock(return_value=(b"The system memory is under warn level pressure.", b""))
                mock.returncode = 0
            return mock

        with (
            patch("platform.system", return_value="Darwin"),
            patch("shutil.which", return_value="/usr/bin/memory_pressure"),
            patch("asyncio.create_subprocess_exec", side_effect=_mock_subprocess),
            patch("asyncio.wait_for", side_effect=_passthrough_wait_for),
        ):
            results = await monitor.check_availability()

        assert len(results) == 1
        assert results[0].available_for_inference is False


# ---------------------------------------------------------------------------
# GPUMonitor public API tests
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestGPUMonitorPublicAPI:
    """Test the public interface of GPUMonitor."""

    def test_classify_process_delegated(self) -> None:
        """classify_process should delegate to the module-level function."""
        monitor = GPUMonitor()
        assert monitor.classify_process("blender", "") == PROCESS_TYPE_RENDERING
        assert monitor.classify_process("python3", "deepspeed train.py") == PROCESS_TYPE_TRAINING

    def test_is_our_process_with_explicit_pids(self) -> None:
        """is_our_process with explicit PID set."""
        monitor = GPUMonitor(our_engine_pids={42, 43})
        assert monitor.is_our_process(42) is True
        with patch("llmhosts.discovery.gpu_monitor._get_process_name", return_value="unknown"):
            assert monitor.is_our_process(99) is False

    def test_default_thresholds(self) -> None:
        """Default threshold values should match spec."""
        monitor = GPUMonitor()
        assert monitor.vram_headroom_mb == 512
        assert monitor.utilization_threshold == 0.85

    def test_custom_thresholds(self) -> None:
        monitor = GPUMonitor(vram_headroom_mb=1024, utilization_threshold=0.50)
        assert monitor.vram_headroom_mb == 1024
        assert monitor.utilization_threshold == 0.50


# ---------------------------------------------------------------------------
# Edge cases
# ---------------------------------------------------------------------------


@pytest.mark.unit
class TestEdgeCases:
    """Edge cases and regression tests."""

    def test_zero_vram_headroom(self) -> None:
        """With 0 headroom, any free VRAM is available."""
        monitor = GPUMonitor(vram_headroom_mb=0)
        available, _ = monitor._evaluate_availability(
            free_vram_mb=1,
            gpu_util_pct=0.0,
            processes=[],
        )
        assert available is True

    def test_zero_free_vram(self) -> None:
        monitor = GPUMonitor(vram_headroom_mb=512)
        available, reason = monitor._evaluate_availability(
            free_vram_mb=0,
            gpu_util_pct=0.0,
            processes=[],
        )
        assert available is False
        assert "headroom" in reason.lower()

    def test_blocking_process_takes_priority_over_utilization(self) -> None:
        """Even if utilization is low, a blocking process should still block."""
        monitor = GPUMonitor()
        processes = [_make_process(name="blender", process_type=PROCESS_TYPE_RENDERING)]
        available, reason = monitor._evaluate_availability(
            free_vram_mb=8192,
            gpu_util_pct=0.01,  # Very low utilization
            processes=processes,
        )
        assert available is False
        assert "rendering" in reason.lower()

    def test_own_engine_with_high_vram_still_available(self) -> None:
        """Our own engine using lots of VRAM should still allow availability check to pass."""
        monitor = GPUMonitor()
        processes = [
            _make_process(
                name="ollama",
                process_type=PROCESS_TYPE_INFERENCE_OWN,
                gpu_memory_mb=6000,
            )
        ]
        available, _ = monitor._evaluate_availability(
            free_vram_mb=2192,  # Above headroom
            gpu_util_pct=0.3,
            processes=processes,
        )
        assert available is True

    def test_dataclass_fields(self) -> None:
        """GPUProcess and GPUAvailability dataclass fields."""
        proc = GPUProcess(pid=1, name="test", gpu_memory_mb=100, process_type="unknown", cmdline="test cmd")
        assert proc.pid == 1
        assert proc.name == "test"
        assert proc.gpu_memory_mb == 100
        assert proc.cmdline == "test cmd"

        avail = GPUAvailability(
            gpu_index=0,
            total_vram_mb=8192,
            used_vram_mb=4096,
            free_vram_mb=4096,
            available_for_inference=True,
            reason="ok",
            processes=[proc],
            gpu_utilization_pct=50.0,
        )
        assert avail.gpu_index == 0
        assert len(avail.processes) == 1
        assert avail.gpu_utilization_pct == 50.0
