from types import SimpleNamespace

import psutil
import pytest

import aitop.core.process.monitor as monitor_module
from aitop.core.process.monitor import AIProcessMonitor


class _FakeProcess:
    def __init__(self, pid: int, name: str) -> None:
        self.pid = pid
        self.info = {"pid": pid, "name": name}


class _CmdlineDeniedProcess:
    def __init__(self, pid: int = 101) -> None:
        self.pid = pid
        self.info = {"pid": pid, "name": "python"}

    def oneshot(self) -> "_CmdlineDeniedProcess":
        return self

    def __enter__(self) -> "_CmdlineDeniedProcess":
        return self

    def __exit__(self, exc_type, exc, tb) -> bool:
        return False

    def cmdline(self) -> list[str]:
        raise psutil.AccessDenied(pid=self.pid, name="python")

    def name(self) -> str:
        return "python"


class _LongCmdlineProcess:
    def __init__(self, pid: int = 202) -> None:
        self.pid = pid
        self.info = {"pid": pid, "name": "python3"}

    def oneshot(self) -> "_LongCmdlineProcess":
        return self

    def __enter__(self) -> "_LongCmdlineProcess":
        return self

    def __exit__(self, exc_type, exc, tb) -> bool:
        return False

    def cmdline(self) -> list[str]:
        return ["python3"] + ["x" * 50 for _ in range(20)]

    def name(self) -> str:
        return "python3"


class _PythonScriptProcess:
    def __init__(
        self, cmdline: list[str], pid: int = 250, proc_name: str = "python3"
    ) -> None:
        self.pid = pid
        self._cmdline = cmdline
        self._proc_name = proc_name
        self.info = {"pid": pid, "name": proc_name}

    def oneshot(self) -> "_PythonScriptProcess":
        return self

    def __enter__(self) -> "_PythonScriptProcess":
        return self

    def __exit__(self, exc_type, exc, tb) -> bool:
        return False

    def cmdline(self) -> list[str]:
        return self._cmdline

    def name(self) -> str:
        return self._proc_name


class _ProcessWithMissingCounters:
    def __init__(self, pid: int = 303) -> None:
        self.pid = pid
        self.info = {"pid": pid, "name": "trainer"}

    def oneshot(self) -> "_ProcessWithMissingCounters":
        return self

    def __enter__(self) -> "_ProcessWithMissingCounters":
        return self

    def __exit__(self, exc_type, exc, tb) -> bool:
        return False

    def ppid(self) -> int:
        return 1

    def name(self) -> str:
        return "trainer"

    def username(self) -> str:
        return "user"

    def cmdline(self) -> list[str]:
        return ["python", "train.py"]

    def cpu_percent(self) -> float:
        return 12.5

    def memory_percent(self) -> float:
        return 3.2

    def status(self) -> str:
        return "running"

    def create_time(self) -> float:
        return 100.0

    def cpu_times(self) -> SimpleNamespace:
        return SimpleNamespace(user=1.0, system=0.5)

    def memory_info(self) -> SimpleNamespace:
        return SimpleNamespace(rss=1024, vms=2048)

    def num_threads(self) -> int:
        return 2

    def io_counters(self) -> SimpleNamespace:
        raise psutil.AccessDenied(pid=self.pid, name="trainer")

    def net_connections(self, kind: str = "inet") -> list[object]:
        raise psutil.AccessDenied(pid=self.pid, name="trainer")

    def cpu_affinity(self) -> list[int]:
        return [0, 1]


class _PidOnlyProcess:
    def __init__(self, pid: int) -> None:
        self.pid = pid


class _RichProcess:
    def __init__(
        self, pid: int, name: str = "trainer", cmdline: str = "python train.py"
    ):
        self.pid = pid
        self.info = {"pid": pid, "name": name}
        self._name = name
        self._cmdline = cmdline

    def oneshot(self) -> "_RichProcess":
        return self

    def __enter__(self) -> "_RichProcess":
        return self

    def __exit__(self, exc_type, exc, tb) -> bool:
        return False

    def ppid(self) -> int:
        return 1

    def name(self) -> str:
        return self._name

    def username(self) -> str:
        return "user"

    def cmdline(self) -> list[str]:
        return self._cmdline.split()

    def cpu_percent(self) -> float:
        return 10.0

    def memory_percent(self) -> float:
        return 5.0

    def status(self) -> str:
        return "running"

    def create_time(self) -> float:
        return 123.0

    def cpu_times(self) -> SimpleNamespace:
        return SimpleNamespace(user=1.0, system=0.5)

    def memory_info(self) -> SimpleNamespace:
        return SimpleNamespace(rss=1024, vms=2048)

    def num_threads(self) -> int:
        return 2

    def io_counters(self) -> SimpleNamespace:
        return SimpleNamespace(read_bytes=1024, write_bytes=2048)

    def net_connections(self, kind: str = "inet") -> list[object]:
        del kind
        return []

    def cpu_affinity(self) -> list[int]:
        return [0]


@pytest.mark.parametrize(
    ("proc_name", "cmdline"),
    [
        ("vllm", "vllm serve meta-llama/Llama-3.1-8B-Instruct"),
        (
            "python",
            "python -m vllm.entrypoints.openai.api_server --model Qwen/Qwen2.5-7B",
        ),
        ("text-generation-launcher", "text-generation-launcher --model-id mistral"),
        ("text-generation-router", "text-generation-router --model-id mistral"),
        ("sglang", "python -m sglang.launch_server --model-path Qwen/Qwen2.5-7B"),
        ("lmdeploy", "lmdeploy serve api_server /models/qwen --backend turbomind"),
        ("xinference-local", "xinference-local --host 0.0.0.0 --port 9997"),
        (
            "llamafactory-cli",
            "llamafactory-cli train examples/train_lora/llama3_lora_sft.yaml",
        ),
        ("axolotl", "axolotl train examples/llama-3/lora-1b.yml"),
        ("litgpt", "litgpt serve microsoft/phi-2"),
        ("invokeai-web", "invokeai-web --ram 8"),
        ("llamafile", "llamafile --server -m Llama-3.2-1B-Instruct.llamafile"),
        ("koboldcpp", "koboldcpp --model model.gguf --usecublas --gpulayers 35"),
        ("trtllm-serve", "trtllm-serve --model /models/llama"),
        ("tritonserver", "tritonserver --model-repository=/models"),
        ("llama-server", "llama-server -m /models/model.gguf"),
        ("ollama", "ollama serve"),
        ("deepspeed", "deepspeed --num_gpus=2 train.py"),
        ("accelerate", "accelerate launch train.py"),
        ("torchrun", "torchrun --nproc-per-node=4 train.py"),
        ("python", "python:finetune_llm.py"),
        ("voxtype", "voxtype --model large-v3 --device cuda"),
        ("python", "python -m faster_whisper transcribe.py --device cuda"),
        ("whisperx", "whisperx meeting.wav --device cuda --model large-v3"),
        (
            "python",
            "python:text-generation-webui/server.py --api --model /models/mistral",
        ),
        ("python", "python:tabbyapi/main.py --model /models/llama"),
        ("python", "python:fooocus/entry_with_update.py --listen"),
    ],
)
def test_is_ai_process_matches_known_runtime_signatures(
    proc_name: str, cmdline: str
) -> None:
    monitor = AIProcessMonitor()

    assert monitor.is_ai_process(proc_name, cmdline) is True


@pytest.mark.parametrize(
    ("proc_name", "cmdline"),
    [
        ("chrome", "chrome --enable-gpu-rasterization --use-gl=desktop"),
        ("nvidia-smi", "nvidia-smi"),
        ("slurmstepd", "slurmstepd: [1234]"),
        ("systemd", "/usr/lib/systemd/systemd --user"),
        ("python", "python app.py --mode serve --port 8080"),
        ("python", "python:backup_metrics.py"),
        ("python", "python:server.py --api --host 0.0.0.0"),
    ],
)
def test_is_ai_process_rejects_common_false_positives(
    proc_name: str, cmdline: str
) -> None:
    monitor = AIProcessMonitor()

    assert monitor.is_ai_process(proc_name, cmdline) is False


def test_is_ai_process_fallback_patterns_are_strict(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    def _raise(*args: object, **kwargs: object) -> list[str]:
        del args, kwargs
        raise RuntimeError("boom")

    monkeypatch.setattr(monitor_module, "load_process_patterns", _raise)

    monitor = monitor_module.AIProcessMonitor()

    assert monitor.is_ai_process("vllm", "vllm serve meta-llama/Llama-3.1-8B") is True
    assert (
        monitor.is_ai_process(
            "chrome", "chrome --enable-gpu-rasterization --use-gl=desktop"
        )
        is False
    )


def test_is_ai_process_cache_is_cmdline_aware_for_python() -> None:
    monitor = AIProcessMonitor()

    assert (
        monitor.is_ai_process(
            "python", "python -m vllm.entrypoints.openai.api_server --model qwen"
        )
        is True
    )
    assert (
        monitor.is_ai_process("python", "python app.py --mode serve --port 8080")
        is False
    )


def test_is_ai_process_cache_is_cmdline_aware_for_non_sensitive_names() -> None:
    monitor = AIProcessMonitor()

    assert monitor.is_ai_process("worker", "worker --health-check") is False
    assert monitor.is_ai_process("worker", "worker --runner torchrun train.py") is True


def test_get_process_cmdline_returns_empty_on_access_denied() -> None:
    monitor = AIProcessMonitor()

    cmdline = monitor._get_process_cmdline(_CmdlineDeniedProcess())

    assert cmdline == ""


def test_get_process_cmdline_truncates_overly_long_cmdline() -> None:
    monitor = AIProcessMonitor()

    cmdline = monitor._get_process_cmdline(_LongCmdlineProcess())

    assert cmdline.endswith("...")
    assert len(cmdline) == 500


def test_get_process_cmdline_preserves_python_project_context_and_hints() -> None:
    monitor = AIProcessMonitor()
    process = _PythonScriptProcess(
        cmdline=[
            "python3",
            "/opt/text-generation-webui/server.py",
            "--api",
            "--model",
            "/models/mistral",
        ]
    )

    cmdline = monitor._get_process_cmdline(process)

    assert cmdline.startswith("python:text-generation-webui/server.py")
    assert "--api" in cmdline
    assert "--model" in cmdline
    assert monitor.is_ai_process("python", cmdline) is True


def test_build_cache_key_truncates_cmdline_for_python() -> None:
    monitor = AIProcessMonitor()
    cmdline = "python " + ("x" * 400)

    key = monitor._build_cache_key("python", cmdline)

    assert key.startswith("python|")
    assert len(key) == len("python|") + 256


def test_get_ai_processes_excludes_unmatched_gpu_processes(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    fake_proc = _FakeProcess(pid=1234, name="chrome")

    monitor._last_full_scan = monitor_module.time.time()

    monkeypatch.setattr(
        monitor_module.psutil, "process_iter", lambda _attrs: [fake_proc]
    )
    monkeypatch.setattr(monitor_module.psutil, "pids", lambda: [1234])
    monkeypatch.setattr(
        monitor,
        "_get_process_cmdline",
        lambda _proc: "chrome --enable-gpu-rasterization",
    )
    monkeypatch.setattr(monitor, "is_ai_process", lambda _name, _cmdline: False)
    monkeypatch.setattr(
        monitor,
        "_get_process_info",
        lambda _proc, _now, include_extended: {
            "pid": 1234,
            "name": "chrome",
            "cmdline": "chrome --enable-gpu-rasterization",
            "extended": include_extended,
        },
    )

    ai_processes = monitor.get_ai_processes(
        gpu_processes=[{"pid": 1234}], force_refresh=False
    )

    assert ai_processes == []


def test_get_process_info_sets_safe_defaults_when_counters_missing() -> None:
    monitor = AIProcessMonitor()
    now = monitor_module.time.time()

    info = monitor._get_process_info(_ProcessWithMissingCounters(), now)

    assert info["io_read_mb_s"] == 0.0
    assert info["io_write_mb_s"] == 0.0
    assert info["net_connections"] == 0


def test_compute_io_rates_handles_counter_resets() -> None:
    monitor = AIProcessMonitor()
    pid = 9001

    first = monitor._compute_io_rates(pid, 100.0, read_bytes=1000.0, write_bytes=500.0)
    second = monitor._compute_io_rates(pid, 101.0, read_bytes=200.0, write_bytes=100.0)

    assert first == {"io_read_mb_s": 0.0, "io_write_mb_s": 0.0}
    assert second["io_read_mb_s"] == 0.0
    assert second["io_write_mb_s"] == 0.0


def test_derive_effective_status_promotes_sleeping_with_gpu_activity() -> None:
    monitor = AIProcessMonitor()

    status = monitor._derive_effective_status(
        raw_status="sleeping",
        cpu_percent=0.0,
        io_read_mb_s=0.0,
        io_write_mb_s=0.0,
        net_connections=0,
        gpu_activity={"sm_util": 32.0},
    )

    assert status == "running"


def test_derive_effective_status_promotes_sleeping_with_host_activity() -> None:
    monitor = AIProcessMonitor()

    status = monitor._derive_effective_status(
        raw_status="sleeping",
        cpu_percent=0.8,
        io_read_mb_s=0.0,
        io_write_mb_s=0.0,
        net_connections=0,
        gpu_activity={},
    )

    assert status == "running"


def test_derive_effective_status_keeps_sleeping_when_idle() -> None:
    monitor = AIProcessMonitor()

    status = monitor._derive_effective_status(
        raw_status="sleeping",
        cpu_percent=0.0,
        io_read_mb_s=0.0,
        io_write_mb_s=0.0,
        net_connections=0,
        gpu_activity={},
    )

    assert status == "sleeping"


def test_derive_effective_status_keeps_hard_states() -> None:
    monitor = AIProcessMonitor()

    status = monitor._derive_effective_status(
        raw_status="zombie",
        cpu_percent=95.0,
        io_read_mb_s=2.0,
        io_write_mb_s=2.0,
        net_connections=3,
        gpu_activity={"sm_util": 90.0},
    )

    assert status == "zombie"


def test_get_ai_processes_keeps_gpu_flag_for_matched_ai_processes(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    fake_proc = _FakeProcess(pid=4321, name="vllm")

    monitor._last_full_scan = monitor_module.time.time()

    monkeypatch.setattr(
        monitor_module.psutil, "process_iter", lambda _attrs: [fake_proc]
    )
    monkeypatch.setattr(monitor_module.psutil, "pids", lambda: [4321])
    monkeypatch.setattr(
        monitor, "_get_process_cmdline", lambda _proc: "vllm serve qwen"
    )
    monkeypatch.setattr(monitor, "is_ai_process", lambda _name, _cmdline: True)
    monkeypatch.setattr(
        monitor,
        "_get_process_info",
        lambda _proc, _now, include_extended: {
            "pid": 4321,
            "name": "vllm",
            "cmdline": "vllm serve qwen",
            "extended": include_extended,
        },
    )

    ai_processes = monitor.get_ai_processes(
        gpu_processes=[{"pid": 4321}], force_refresh=False
    )

    assert len(ai_processes) == 1
    assert ai_processes[0]["pid"] == 4321
    assert ai_processes[0]["is_gpu_process"] is True


def test_get_ai_processes_checks_python_cmdline_without_full_scan(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    non_ai_proc = _FakeProcess(pid=7001, name="python")
    ai_proc = _FakeProcess(pid=7002, name="python")
    cmd_by_pid = {
        7001: "python:app.py",
        7002: "python:train.py",
    }

    monitor._last_full_scan = monitor_module.time.time()

    monkeypatch.setattr(
        monitor_module.psutil, "process_iter", lambda _attrs: [non_ai_proc, ai_proc]
    )
    monkeypatch.setattr(monitor_module.psutil, "pids", lambda: [7001, 7002])
    monkeypatch.setattr(
        monitor, "_get_process_cmdline", lambda proc: cmd_by_pid[proc.pid]
    )
    monkeypatch.setattr(
        monitor,
        "_get_process_info",
        lambda proc, _now, include_extended: {
            "pid": proc.pid,
            "name": proc.info["name"],
            "cmdline": cmd_by_pid[proc.pid],
            "extended": include_extended,
            "cpu_percent": 0.0,
            "memory_percent": 0.0,
            "status": "running",
        },
    )

    ai_processes = monitor.get_ai_processes(gpu_processes=[], force_refresh=False)

    assert [proc["pid"] for proc in ai_processes] == [7002]


def test_get_ai_processes_adds_job_topology_and_straggler_fields(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    rank0 = _FakeProcess(pid=8101, name="python")
    rank1 = _FakeProcess(pid=8102, name="python")

    monitor._last_full_scan = monitor_module.time.time()

    monkeypatch.setattr(
        monitor_module.psutil, "process_iter", lambda _attrs: [rank0, rank1]
    )
    monkeypatch.setattr(monitor_module.psutil, "pids", lambda: [8101, 8102])
    monkeypatch.setattr(
        monitor,
        "_get_process_cmdline",
        lambda proc: (
            "torchrun --rdzv-id run-123 --rank 0 train.py"
            if proc.pid == 8101
            else "torchrun --rdzv-id run-123 --rank 1 train.py"
        ),
    )
    monkeypatch.setattr(monitor, "is_ai_process", lambda _name, _cmdline: True)
    monkeypatch.setattr(
        monitor,
        "_get_process_info",
        lambda proc, _now, include_extended: {
            "pid": proc.pid,
            "name": proc.info["name"],
            "cmdline": (
                "torchrun --rdzv-id run-123 --rank 0 train.py"
                if proc.pid == 8101
                else "torchrun --rdzv-id run-123 --rank 1 train.py"
            ),
            "extended": include_extended,
            "cpu_percent": 80.0 if proc.pid == 8101 else 2.0,
            "memory_percent": 1.0,
            "status": "running",
        },
    )

    ai_processes = monitor.get_ai_processes(
        gpu_processes=[{"pid": 8101, "sm_util": 95.0}, {"pid": 8102, "sm_util": 1.0}],
        force_refresh=False,
    )

    assert len(ai_processes) == 2
    assert {proc["job_id"] for proc in ai_processes} == {"run-run-123"}
    assert {proc["job_size"] for proc in ai_processes} == {2}
    assert {proc["is_distributed_job"] for proc in ai_processes} == {True}

    by_pid = {proc["pid"]: proc for proc in ai_processes}
    assert by_pid[8102]["is_job_straggler"] is True
    assert by_pid[8101]["is_job_straggler"] is False


def test_visibility_grace_window_retains_recent_process_for_120s(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    base_time = 1000.0
    process = {
        "pid": 12001,
        "name": "ollama",
        "create_time": 111.0,
        "cpu_percent": 0.0,
        "memory_percent": 1.0,
        "status": "sleeping",
    }

    initial = monitor._apply_visibility_grace_window([process], base_time)
    assert len(initial) == 1
    assert initial[0]["grace_retained"] is False

    monkeypatch.setattr(monitor, "_is_cached_process_alive", lambda _cached: True)

    retained = monitor._apply_visibility_grace_window([], base_time + 30.0)
    assert len(retained) == 1
    assert retained[0]["pid"] == 12001
    assert retained[0]["grace_retained"] is True
    assert retained[0]["grace_age_s"] == 30.0

    expired = monitor._apply_visibility_grace_window([], base_time + 130.0)
    assert expired == []


def test_visibility_grace_window_drops_dead_cached_process(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    base_time = monitor_module.time.time()
    cached_process = {"pid": 4242, "create_time": base_time - 10.0}

    monitor._ai_visibility_cache[4242] = cached_process
    monitor._ai_visibility_timestamps[4242] = base_time - 10.0
    monkeypatch.setattr(monitor, "_is_cached_process_alive", lambda _cached: False)

    retained = monitor._apply_visibility_grace_window([], base_time)

    assert retained == []
    assert monitor._collection_stats["grace_retained"] == 0


def test_prune_stale_data_drops_missing_and_old_pids(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    monitor._process_info_cache = {1: {"pid": 1}, 2: {"pid": 2}}
    monitor._process_info_timestamps = {1: 90.0, 2: 70.0}
    monitor._process_io_cache = {1: {"read_bytes": 0}, 2: {"read_bytes": 0}}
    monitor._pid_timestamp = {1: 95.0, 2: 50.0}
    monitor._last_seen_pids = {1, 2}
    monitor._pid_staleness_threshold = 10.0
    monitor._last_cache_clear = 0.0
    monitor._cache_timeout = 1.0

    monkeypatch.setattr(
        monitor_module.psutil,
        "process_iter",
        lambda _attrs: [_PidOnlyProcess(1)],
    )

    monitor._prune_stale_data(current_time=100.0)

    assert set(monitor._process_info_cache.keys()) == {1}
    assert set(monitor._process_info_timestamps.keys()) == {1}
    assert set(monitor._process_io_cache.keys()) == {1}
    assert monitor._last_seen_pids == set()


def test_is_ai_process_clears_name_cache_on_size_cap() -> None:
    monitor = AIProcessMonitor()
    monitor._max_name_cache_size = 2
    monitor._cache_timeout = 3600

    assert monitor.is_ai_process("proc-a") is False
    assert monitor.is_ai_process("proc-b") is False
    assert monitor.is_ai_process("proc-c") is False

    assert len(monitor._known_ai_processes) + len(monitor._known_non_ai_processes) <= 2
    assert "proc-a" not in monitor._known_non_ai_processes
    assert "proc-b" in monitor._known_non_ai_processes
    assert "proc-c" in monitor._known_non_ai_processes


def test_prune_stale_data_skips_on_process_iter_failure(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    monitor._process_info_cache = {1: {"pid": 1}}
    monitor._process_info_timestamps = {1: 90.0}
    monitor._process_io_cache = {1: {"read_bytes": 0.0}}
    monitor._pid_timestamp = {1: 90.0}

    def _raise(_attrs: object) -> list[object]:
        raise psutil.AccessDenied(pid=1, name="proc")

    monkeypatch.setattr(monitor_module.psutil, "process_iter", _raise)

    monitor._prune_stale_data(current_time=100.0)

    assert set(monitor._process_info_cache.keys()) == {1}
    assert set(monitor._process_info_timestamps.keys()) == {1}
    assert set(monitor._process_io_cache.keys()) == {1}


def test_get_process_info_eviction_prunes_auxiliary_pid_caches() -> None:
    monitor = AIProcessMonitor()
    monitor._max_cache_size = 10
    monitor._process_info_cache = {pid: {"pid": pid} for pid in range(1, 11)}
    monitor._process_info_timestamps = {pid: float(pid) for pid in range(1, 11)}
    monitor._process_io_cache = {
        pid: {"timestamp": 0.0, "read_bytes": 0.0, "write_bytes": 0.0}
        for pid in range(1, 11)
    }
    monitor._pid_timestamp = {pid: 50.0 for pid in range(1, 11)}
    monitor._last_seen_pids = set(range(1, 11))

    info = monitor._get_process_info(_RichProcess(pid=999), current_time=200.0)

    assert info["pid"] == 999
    assert 1 not in monitor._process_info_cache
    assert 1 not in monitor._process_info_timestamps
    assert 1 not in monitor._process_io_cache
    assert 1 not in monitor._pid_timestamp
    assert 1 not in monitor._last_seen_pids


def test_get_ai_processes_force_refresh_clears_all_pid_caches(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    monitor._process_info_cache = {10: {"pid": 10}}
    monitor._process_info_timestamps = {10: 1.0}
    monitor._process_io_cache = {10: {"timestamp": 1.0, "read_bytes": 0.0}}
    monitor._pid_timestamp = {10: 1.0}
    monitor._last_seen_pids = {10}

    monkeypatch.setattr(monitor_module.psutil, "process_iter", lambda _attrs: [])
    monkeypatch.setattr(monitor_module.psutil, "pids", lambda: [])

    processes = monitor.get_ai_processes(force_refresh=True)

    assert processes == []
    assert monitor._process_info_cache == {}
    assert monitor._process_info_timestamps == {}
    assert monitor._process_io_cache == {}
    assert monitor._pid_timestamp == {}
    assert monitor._last_seen_pids == set()


def test_get_ai_processes_handles_process_iter_failure_with_grace_window(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    now = monitor_module.time.time()
    monitor._ai_visibility_cache[777] = {
        "pid": 777,
        "name": "vllm",
        "create_time": now - 10.0,
        "cpu_percent": 2.0,
        "memory_percent": 1.0,
        "status": "running",
    }
    monitor._ai_visibility_timestamps[777] = now - 10.0

    monkeypatch.setattr(monitor, "_is_cached_process_alive", lambda _cached: True)

    def _raise_iter(_attrs: object) -> list[object]:
        raise psutil.AccessDenied(pid=777, name="vllm")

    def _raise_pids() -> list[int]:
        raise OSError("pid table unavailable")

    monkeypatch.setattr(monitor_module.psutil, "process_iter", _raise_iter)
    monkeypatch.setattr(monitor_module.psutil, "pids", _raise_pids)

    processes = monitor.get_ai_processes(force_refresh=False)

    assert len(processes) == 1
    assert processes[0]["pid"] == 777
    assert processes[0]["grace_retained"] is True
    assert monitor.get_collection_stats()["total_processes"] == 0


def test_get_ai_processes_treats_string_gpu_pid_as_gpu_process(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    fake_proc = _FakeProcess(pid=9021, name="python")

    monitor._last_full_scan = monitor_module.time.time()

    monkeypatch.setattr(
        monitor_module.psutil, "process_iter", lambda _attrs: [fake_proc]
    )
    monkeypatch.setattr(monitor_module.psutil, "pids", lambda: [9021])
    monkeypatch.setattr(monitor, "_get_process_cmdline", lambda _proc: "python app.py")
    monkeypatch.setattr(monitor, "is_ai_process", lambda _name, _cmdline: False)
    monkeypatch.setattr(
        monitor,
        "_get_process_info",
        lambda _proc, _now, include_extended: {
            "pid": 9021,
            "name": "python",
            "cmdline": "python app.py",
            "extended": include_extended,
            "cpu_percent": 35.0,
            "memory_percent": 2.0,
            "status": "running",
        },
    )

    ai_processes = monitor.get_ai_processes(
        gpu_processes=[{"pid": "9021", "memory": 1024.0, "sm_util": 70.0}],
        force_refresh=False,
    )

    assert len(ai_processes) == 1
    assert ai_processes[0]["pid"] == 9021
    assert ai_processes[0]["is_gpu_process"] is True


def test_get_ai_processes_detects_voxtype_speech_to_text_workload(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    fake_proc = _FakeProcess(pid=9901, name="voxtype")

    monitor._last_full_scan = monitor_module.time.time()

    monkeypatch.setattr(
        monitor_module.psutil, "process_iter", lambda _attrs: [fake_proc]
    )
    monkeypatch.setattr(monitor_module.psutil, "pids", lambda: [9901])
    monkeypatch.setattr(
        monitor,
        "_get_process_cmdline",
        lambda _proc: "voxtype --model large-v3 --task transcribe --device cuda",
    )
    monkeypatch.setattr(
        monitor,
        "_get_process_info",
        lambda _proc, _now, include_extended: {
            "pid": 9901,
            "name": "voxtype",
            "cmdline": "voxtype --model large-v3 --task transcribe --device cuda",
            "extended": include_extended,
            "cpu_percent": 75.0,
            "memory_percent": 3.0,
            "status": "running",
        },
    )

    ai_processes = monitor.get_ai_processes(
        gpu_processes=[{"pid": "9901", "memory": 2048.0, "sm_util": 82.0}],
        force_refresh=False,
    )

    assert len(ai_processes) == 1
    assert ai_processes[0]["pid"] == 9901
    assert ai_processes[0]["is_gpu_process"] is True


def test_get_ai_processes_gpu_fallback_excludes_known_graphics_processes(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    fake_proc = _FakeProcess(pid=9022, name="chrome")

    monitor._last_full_scan = monitor_module.time.time()

    monkeypatch.setattr(
        monitor_module.psutil, "process_iter", lambda _attrs: [fake_proc]
    )
    monkeypatch.setattr(monitor_module.psutil, "pids", lambda: [9022])
    monkeypatch.setattr(
        monitor,
        "_get_process_cmdline",
        lambda _proc: "chrome --enable-gpu-rasterization --profile-directory=Default",
    )
    monkeypatch.setattr(monitor, "is_ai_process", lambda _name, _cmdline: False)
    monkeypatch.setattr(
        monitor,
        "_get_process_info",
        lambda _proc, _now, include_extended: {
            "pid": 9022,
            "name": "chrome",
            "cmdline": "chrome --enable-gpu-rasterization",
            "extended": include_extended,
            "cpu_percent": 5.0,
            "memory_percent": 1.0,
            "status": "sleeping",
        },
    )

    ai_processes = monitor.get_ai_processes(
        gpu_processes=[{"pid": 9022, "memory": 512.0, "sm_util": 50.0}],
        force_refresh=False,
    )

    assert ai_processes == []


def test_get_ai_processes_gpu_fallback_detects_generic_asr_cuda_command(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    fake_proc = _FakeProcess(pid=9033, name="worker")

    monitor._last_full_scan = monitor_module.time.time()

    monkeypatch.setattr(
        monitor_module.psutil, "process_iter", lambda _attrs: [fake_proc]
    )
    monkeypatch.setattr(monitor_module.psutil, "pids", lambda: [9033])
    monkeypatch.setattr(
        monitor,
        "_get_process_cmdline",
        lambda _proc: (
            "worker --mode asr --task transcribe --engine ctranslate2 --device cuda"
        ),
    )
    monkeypatch.setattr(monitor, "is_ai_process", lambda _name, _cmdline: False)
    monkeypatch.setattr(
        monitor,
        "_get_process_info",
        lambda _proc, _now, include_extended: {
            "pid": 9033,
            "name": "worker",
            "cmdline": (
                "worker --mode asr --task transcribe --engine ctranslate2 "
                "--device cuda"
            ),
            "extended": include_extended,
            "cpu_percent": 20.0,
            "memory_percent": 1.0,
            "status": "running",
        },
    )

    ai_processes = monitor.get_ai_processes(
        gpu_processes=[{"pid": "9033", "memory": 1024.0, "sm_util": 62.0}],
        force_refresh=False,
    )

    assert len(ai_processes) == 1
    assert ai_processes[0]["pid"] == 9033
    assert ai_processes[0]["is_gpu_process"] is True


def test_get_ai_processes_promotes_sleeping_status_when_gpu_active(
    monkeypatch: pytest.MonkeyPatch,
) -> None:
    monitor = AIProcessMonitor()
    fake_proc = _FakeProcess(pid=9044, name="python")

    monitor._last_full_scan = monitor_module.time.time()

    monkeypatch.setattr(
        monitor_module.psutil, "process_iter", lambda _attrs: [fake_proc]
    )
    monkeypatch.setattr(monitor_module.psutil, "pids", lambda: [9044])
    monkeypatch.setattr(
        monitor, "_get_process_cmdline", lambda _proc: "python train.py --device cuda"
    )
    monkeypatch.setattr(monitor, "is_ai_process", lambda _name, _cmdline: True)
    monkeypatch.setattr(
        monitor,
        "_get_process_info",
        lambda _proc, _now, include_extended: {
            "pid": 9044,
            "name": "python",
            "cmdline": "python train.py --device cuda",
            "extended": include_extended,
            "cpu_percent": 0.0,
            "memory_percent": 1.0,
            "status": "sleeping",
            "raw_status": "sleeping",
            "io_read_mb_s": 0.0,
            "io_write_mb_s": 0.0,
            "net_connections": 0,
        },
    )

    ai_processes = monitor.get_ai_processes(
        gpu_processes=[{"pid": 9044, "sm_util": 41.0}],
        force_refresh=False,
    )

    assert len(ai_processes) == 1
    assert ai_processes[0]["status"] == "running"
    assert ai_processes[0]["raw_status"] == "sleeping"
