"""Passive GPU monitoring -- process classification and availability for GPU Respect Architecture.

The GPU Respect Architecture is built on one principle: **user workloads have
absolute priority**.  LLMHosts never kills, modifies, or interferes with
anything running on the GPU.  This module passively reads GPU state and
classifies running processes so the dispatcher can make informed routing
decisions -- yielding to the user when their GPU is busy.

Supported platforms:
- NVIDIA: pynvml (preferred), nvidia-smi subprocess fallback
- AMD: rocm-smi subprocess
- Apple Silicon: memory-pressure heuristic (no per-process GPU visibility)
- CPU-only: graceful no-op (always "available")

Performance target: <50 ms per call (called before each routing decision).
"""

from __future__ import annotations

import asyncio
import logging
import os
import platform
import shutil
from dataclasses import dataclass, field

logger = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# Optional pynvml import -- graceful degradation when unavailable
# ---------------------------------------------------------------------------

try:
    import pynvml

    _pynvml_available = True
except ImportError:
    pynvml = None  # type: ignore[assignment]
    _pynvml_available = False

# ---------------------------------------------------------------------------
# Process-type classification constants
# ---------------------------------------------------------------------------

PROCESS_TYPE_INFERENCE_OWN = "inference_own"
PROCESS_TYPE_INFERENCE_FOREIGN = "inference_foreign"
PROCESS_TYPE_TRAINING = "training"
PROCESS_TYPE_RENDERING = "rendering"
PROCESS_TYPE_COMPUTE = "compute"
PROCESS_TYPE_GAMING = "gaming"
PROCESS_TYPE_SYSTEM = "system"
PROCESS_TYPE_UNKNOWN = "unknown"

# Keywords (lowercase) used for heuristic process classification.
# Checked against both process name and full command line.

_TRAINING_KEYWORDS: frozenset[str] = frozenset(
    {
        "train",
        "finetune",
        "fine-tune",
        "fine_tune",
        "deepspeed",
        "accelerate",
        "nccl",
        "megatron",
        "fairseq",
        "pytorch_lightning",
        "lightning",
        "trainer",
        "transformers",
        "huggingface",
        "lora",
        "qlora",
        "axolotl",
        "unsloth",
    }
)

_RENDERING_KEYWORDS: frozenset[str] = frozenset(
    {
        "blender",
        "resolve",
        "davinci",
        "premiere",
        "after effects",
        "afterfx",
        "unity",
        "unreal",
        "godot",
        "obs",
        "obs-studio",
        "obs64",
        "ffmpeg",
        "handbrake",
        "kdenlive",
        "shotcut",
        "natron",
        "nuke",
        "maya",
        "3dsmax",
        "cinema4d",
        "houdini",
        "substance",
        "renderman",
        "arnold",
        "v-ray",
        "vray",
        "octane",
        "redshift",
    }
)

_GAMING_KEYWORDS: frozenset[str] = frozenset(
    {
        "steam",
        "steamwebhelper",
        "gameoverlayui",
        "epicgameslauncher",
        "easyanticheat",
        "battleye",
        "wine",
        "proton",
        "lutris",
        "gamescope",
        "mangohud",
        "dxvk",
        "vkd3d",
    }
)

_COMPUTE_KEYWORDS: frozenset[str] = frozenset(
    {
        "hashcat",
        "mining",
        "ethminer",
        "xmrig",
        "nbminer",
        "gminer",
        "t-rex",
        "phoenixminer",
        "claymore",
        "folding",
        "fah",
        "boinc",
        "matlab",
        "mathematica",
        "julia",
        "rapids",
        "cudf",
        "cuml",
        "cugraph",
    }
)

_INFERENCE_ENGINE_KEYWORDS: frozenset[str] = frozenset(
    {
        "ollama",
        "vllm",
        "llama.cpp",
        "llama-server",
        "llama_cpp",
        "text-generation",
        "tgi",
        "localai",
        "lmstudio",
        "lm-studio",
        "lm studio",
        "koboldcpp",
        "oobabooga",
        "exllama",
        "exllamav2",
        "llamafile",
        "mlx_lm",
        "mlx-lm",
        "tabby",
        "tabbyml",
        "jan",
        "gpt4all",
    }
)

_SYSTEM_KEYWORDS: frozenset[str] = frozenset(
    {
        "xorg",
        "x11",
        "xwayland",
        "wayland",
        "gnome-shell",
        "kwin",
        "plasmashell",
        "mutter",
        "sway",
        "compositor",
        "dwm",
        "picom",
        "compton",
        "nvidia-smi",
        "nvidia-persistenced",
        "nvidia-settings",
        "nv-controlpanel",
        "amdgpu",
        "gdm",
        "sddm",
        "lightdm",
        "loginctl",
        "systemd",
    }
)

# Python-based processes need extra command-line inspection
_PYTHON_EXECUTABLES: frozenset[str] = frozenset({"python", "python3", "python3.10", "python3.11", "python3.12"})

# Processes that are always "ours" when launched by our parent tree
_OUR_ENGINE_NAMES: frozenset[str] = frozenset({"ollama", "ollama_llama_server", "ollama-runner"})


# ---------------------------------------------------------------------------
# Data classes
# ---------------------------------------------------------------------------


@dataclass
class GPUProcess:
    """A single process using the GPU."""

    pid: int
    name: str
    gpu_memory_mb: int
    process_type: str = PROCESS_TYPE_UNKNOWN
    cmdline: str = ""


@dataclass
class GPUAvailability:
    """Availability status for a single GPU."""

    gpu_index: int
    total_vram_mb: int
    used_vram_mb: int
    free_vram_mb: int
    available_for_inference: bool
    reason: str
    processes: list[GPUProcess] = field(default_factory=list)
    gpu_utilization_pct: float = 0.0


# ---------------------------------------------------------------------------
# GPUMonitor -- the main class
# ---------------------------------------------------------------------------


class GPUMonitor:
    """Passively monitors GPU state and determines inference availability.

    Never kills, modifies, or interferes with any running process.  All checks
    are read-only.  When pynvml is available it is preferred (pure Python
    bindings, no subprocess overhead).  Falls back to ``nvidia-smi`` parsing
    when pynvml is absent.

    Parameters
    ----------
    vram_headroom_mb:
        Always leave this much VRAM free for OS/driver/system stability.
        Default 512 MB.
    utilization_threshold:
        If GPU compute utilisation exceeds this fraction, consider the GPU
        busy even if VRAM is technically free.  Default 0.85 (85 %).
    our_engine_pids:
        Optional set of PIDs belonging to inference engines that *we* manage.
        These are excluded from "busy" calculations.
    """

    def __init__(
        self,
        *,
        vram_headroom_mb: int = 512,
        utilization_threshold: float = 0.85,
        our_engine_pids: set[int] | None = None,
    ) -> None:
        self.vram_headroom_mb = vram_headroom_mb
        self.utilization_threshold = utilization_threshold
        self.our_engine_pids: set[int] = our_engine_pids or set()
        self._nvml_initialized = False

    # -- public API ---------------------------------------------------------

    async def check_availability(self) -> list[GPUAvailability]:
        """Check all GPUs and return per-GPU availability.

        This is the primary entry point.  Safe to call frequently (<50 ms).
        Returns an empty list on CPU-only machines (no GPUs = always available
        for CPU inference).
        """
        system = platform.system()

        if system == "Darwin":
            return await self._check_apple_silicon()

        # Try pynvml first (fast, no subprocess)
        if _pynvml_available:
            try:
                return await self._check_nvidia_pynvml()
            except Exception:
                logger.debug("pynvml check failed, trying nvidia-smi fallback", exc_info=True)

        # nvidia-smi fallback
        if shutil.which("nvidia-smi"):
            try:
                return await self._check_nvidia_smi()
            except Exception:
                logger.debug("nvidia-smi fallback failed", exc_info=True)

        # AMD via rocm-smi
        if shutil.which("rocm-smi"):
            try:
                return await self._check_amd_rocm()
            except Exception:
                logger.debug("rocm-smi check failed", exc_info=True)

        # No GPU tools at all -- CPU-only machine
        return []

    def classify_process(self, name: str, cmdline: str = "") -> str:
        """Classify a GPU process by its name and optional command line.

        Public method so callers (and tests) can verify classification.
        """
        return _classify_process(name, cmdline)

    def is_our_process(self, pid: int) -> bool:
        """Check whether *pid* belongs to an inference engine we manage."""
        return _is_our_process(pid, self.our_engine_pids)

    # -- NVIDIA via pynvml --------------------------------------------------

    async def _check_nvidia_pynvml(self) -> list[GPUAvailability]:
        """Use pynvml to check all NVIDIA GPUs.  Runs in executor to avoid blocking."""
        loop = asyncio.get_running_loop()
        return await loop.run_in_executor(None, self._check_nvidia_pynvml_sync)

    def _check_nvidia_pynvml_sync(self) -> list[GPUAvailability]:
        """Synchronous pynvml check (runs inside executor)."""
        if not self._nvml_initialized:
            pynvml.nvmlInit()
            self._nvml_initialized = True

        device_count = pynvml.nvmlDeviceGetCount()
        results: list[GPUAvailability] = []

        for idx in range(device_count):
            handle = pynvml.nvmlDeviceGetHandleByIndex(idx)
            mem_info = pynvml.nvmlDeviceGetMemoryInfo(handle)

            # GPU utilization
            try:
                utilization = pynvml.nvmlDeviceGetUtilizationRates(handle)
                gpu_util_pct = utilization.gpu / 100.0
            except Exception:
                gpu_util_pct = 0.0

            # Process enumeration
            processes: list[GPUProcess] = []
            try:
                compute_procs = pynvml.nvmlDeviceGetComputeRunningProcesses(handle)
                for proc in compute_procs:
                    proc_name = _get_process_name(proc.pid)
                    proc_cmdline = _get_process_cmdline(proc.pid)
                    proc_type = _classify_process(proc_name, proc_cmdline)
                    if _is_our_process(proc.pid, self.our_engine_pids):
                        proc_type = PROCESS_TYPE_INFERENCE_OWN
                    processes.append(
                        GPUProcess(
                            pid=proc.pid,
                            name=proc_name,
                            gpu_memory_mb=proc.usedGpuMemory // (1024 * 1024) if proc.usedGpuMemory else 0,
                            process_type=proc_type,
                            cmdline=proc_cmdline,
                        )
                    )
            except Exception:
                logger.debug("Failed to enumerate GPU %d processes via pynvml", idx, exc_info=True)

            # Also check graphics processes (rendering, gaming, display)
            try:
                graphics_procs = pynvml.nvmlDeviceGetGraphicsRunningProcesses(handle)
                existing_pids = {p.pid for p in processes}
                for proc in graphics_procs:
                    if proc.pid in existing_pids:
                        continue
                    proc_name = _get_process_name(proc.pid)
                    proc_cmdline = _get_process_cmdline(proc.pid)
                    proc_type = _classify_process(proc_name, proc_cmdline)
                    if _is_our_process(proc.pid, self.our_engine_pids):
                        proc_type = PROCESS_TYPE_INFERENCE_OWN
                    processes.append(
                        GPUProcess(
                            pid=proc.pid,
                            name=proc_name,
                            gpu_memory_mb=proc.usedGpuMemory // (1024 * 1024) if proc.usedGpuMemory else 0,
                            process_type=proc_type,
                            cmdline=proc_cmdline,
                        )
                    )
            except Exception:
                logger.debug("Failed to enumerate GPU %d graphics processes", idx, exc_info=True)

            total_mb = mem_info.total // (1024 * 1024)
            used_mb = mem_info.used // (1024 * 1024)
            free_mb = mem_info.free // (1024 * 1024)

            available, reason = self._evaluate_availability(
                free_vram_mb=free_mb,
                gpu_util_pct=gpu_util_pct,
                processes=processes,
            )

            results.append(
                GPUAvailability(
                    gpu_index=idx,
                    total_vram_mb=total_mb,
                    used_vram_mb=used_mb,
                    free_vram_mb=free_mb,
                    available_for_inference=available,
                    reason=reason,
                    processes=processes,
                    gpu_utilization_pct=gpu_util_pct * 100.0,
                )
            )

        return results

    # -- NVIDIA via nvidia-smi (fallback) -----------------------------------

    async def _check_nvidia_smi(self) -> list[GPUAvailability]:
        """Parse nvidia-smi output as a fallback when pynvml is unavailable."""
        # Get memory and utilization
        proc = await asyncio.create_subprocess_exec(
            "nvidia-smi",
            "--query-gpu=memory.total,memory.used,memory.free,utilization.gpu",
            "--format=csv,nounits,noheader",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10.0)
        if proc.returncode != 0:
            return []

        # Get per-GPU process list
        proc2 = await asyncio.create_subprocess_exec(
            "nvidia-smi",
            "--query-compute-apps=pid,used_gpu_memory,name",
            "--format=csv,nounits,noheader",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        proc_stdout, _ = await asyncio.wait_for(proc2.communicate(), timeout=10.0)

        # Parse process list into a dict keyed by GPU index
        # nvidia-smi query-compute-apps doesn't include GPU index by default,
        # so we parse the pmon output instead for GPU assignment
        gpu_processes: dict[int, list[GPUProcess]] = {}
        if proc2.returncode == 0:
            for line in proc_stdout.decode().strip().splitlines():
                parts = [p.strip() for p in line.split(",")]
                if len(parts) < 3:
                    continue
                try:
                    pid = int(parts[0])
                    mem_mb = int(parts[1]) if parts[1].strip() not in ("", "[N/A]") else 0
                    name = parts[2]
                except (ValueError, IndexError):
                    continue
                cmdline = _get_process_cmdline(pid)
                proc_type = _classify_process(name, cmdline)
                if _is_our_process(pid, self.our_engine_pids):
                    proc_type = PROCESS_TYPE_INFERENCE_OWN
                gp = GPUProcess(pid=pid, name=name, gpu_memory_mb=mem_mb, process_type=proc_type, cmdline=cmdline)
                # Without GPU index info, assign to GPU 0 (best effort)
                gpu_processes.setdefault(0, []).append(gp)

        results: list[GPUAvailability] = []
        for idx, line in enumerate(stdout.decode().strip().splitlines()):
            parts = [p.strip() for p in line.split(",")]
            if len(parts) < 4:
                continue
            try:
                total_mb = int(float(parts[0]))
                used_mb = int(float(parts[1]))
                free_mb = int(float(parts[2]))
                gpu_util_raw = parts[3].strip()
                gpu_util_pct = float(gpu_util_raw) / 100.0 if gpu_util_raw not in ("", "[N/A]") else 0.0
            except (ValueError, IndexError):
                continue

            processes = gpu_processes.get(idx, [])
            available, reason = self._evaluate_availability(
                free_vram_mb=free_mb,
                gpu_util_pct=gpu_util_pct,
                processes=processes,
            )
            results.append(
                GPUAvailability(
                    gpu_index=idx,
                    total_vram_mb=total_mb,
                    used_vram_mb=used_mb,
                    free_vram_mb=free_mb,
                    available_for_inference=available,
                    reason=reason,
                    processes=processes,
                    gpu_utilization_pct=gpu_util_pct * 100.0,
                )
            )
        return results

    # -- AMD via rocm-smi ---------------------------------------------------

    async def _check_amd_rocm(self) -> list[GPUAvailability]:
        """Check AMD GPU availability via rocm-smi."""
        proc = await asyncio.create_subprocess_exec(
            "rocm-smi",
            "--showmeminfo",
            "vram",
            "--csv",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10.0)
        if proc.returncode != 0:
            return []

        # Also get utilization
        util_proc = await asyncio.create_subprocess_exec(
            "rocm-smi",
            "--showuse",
            "--csv",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        util_stdout, _ = await asyncio.wait_for(util_proc.communicate(), timeout=10.0)
        gpu_utils: list[float] = []
        if util_proc.returncode == 0:
            for line in util_stdout.decode().strip().splitlines()[1:]:  # skip header
                parts = [p.strip() for p in line.split(",")]
                if len(parts) >= 2:
                    try:
                        gpu_utils.append(float(parts[1]) / 100.0)
                    except ValueError:
                        gpu_utils.append(0.0)

        results: list[GPUAvailability] = []
        for idx, line in enumerate(stdout.decode().strip().splitlines()[1:]):  # skip header
            parts = [p.strip() for p in line.split(",")]
            if len(parts) < 3:
                continue
            try:
                vram_total_bytes = int(parts[1]) if parts[1].isdigit() else 0
                vram_used_bytes = int(parts[2]) if parts[2].isdigit() else 0
            except (ValueError, IndexError):
                continue

            total_mb = vram_total_bytes // (1024 * 1024)
            used_mb = vram_used_bytes // (1024 * 1024)
            free_mb = max(total_mb - used_mb, 0)
            gpu_util_pct = gpu_utils[idx] if idx < len(gpu_utils) else 0.0

            # AMD doesn't expose per-process GPU usage easily -- use empty list
            available, reason = self._evaluate_availability(
                free_vram_mb=free_mb,
                gpu_util_pct=gpu_util_pct,
                processes=[],
            )
            results.append(
                GPUAvailability(
                    gpu_index=idx,
                    total_vram_mb=total_mb,
                    used_vram_mb=used_mb,
                    free_vram_mb=free_mb,
                    available_for_inference=available,
                    reason=reason,
                    processes=[],
                    gpu_utilization_pct=gpu_util_pct * 100.0,
                )
            )
        return results

    # -- Apple Silicon (unified memory) -------------------------------------

    async def _check_apple_silicon(self) -> list[GPUAvailability]:
        """Check Apple Silicon memory pressure.

        Apple Silicon has unified memory shared between CPU and GPU.  There is
        no per-process GPU memory API.  We use ``memory_pressure`` to determine
        whether the system is under pressure (in which case we yield).
        """
        # Get total memory
        total_proc = await asyncio.create_subprocess_exec(
            "sysctl",
            "-n",
            "hw.memsize",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        total_stdout, _ = await asyncio.wait_for(total_proc.communicate(), timeout=5.0)
        if total_proc.returncode != 0:
            return []

        try:
            total_bytes = int(total_stdout.decode().strip())
        except ValueError:
            return []
        total_mb = total_bytes // (1024 * 1024)

        # Check memory pressure (macOS-specific)
        pressure_level = "normal"
        if shutil.which("memory_pressure"):
            mp_proc = await asyncio.create_subprocess_exec(
                "memory_pressure",
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.PIPE,
            )
            mp_stdout, _ = await asyncio.wait_for(mp_proc.communicate(), timeout=5.0)
            if mp_proc.returncode == 0:
                output = mp_stdout.decode().lower()
                if "critical" in output:
                    pressure_level = "critical"
                elif "warn" in output:
                    pressure_level = "warn"

        # Apply Apple Silicon overhead (4 GB for OS, matching models.py)
        os_overhead_mb = 4096
        effective_mb = max(total_mb - os_overhead_mb, 0)

        if pressure_level == "critical":
            return [
                GPUAvailability(
                    gpu_index=0,
                    total_vram_mb=effective_mb,
                    used_vram_mb=effective_mb,
                    free_vram_mb=0,
                    available_for_inference=False,
                    reason="Memory pressure critical -- yielding to existing workloads",
                    gpu_utilization_pct=100.0,
                )
            ]
        elif pressure_level == "warn":
            return [
                GPUAvailability(
                    gpu_index=0,
                    total_vram_mb=effective_mb,
                    used_vram_mb=effective_mb // 2,
                    free_vram_mb=effective_mb // 2,
                    available_for_inference=False,
                    reason="Memory pressure elevated -- yielding to existing workloads",
                    gpu_utilization_pct=50.0,
                )
            ]

        return [
            GPUAvailability(
                gpu_index=0,
                total_vram_mb=effective_mb,
                used_vram_mb=0,
                free_vram_mb=effective_mb,
                available_for_inference=True,
                reason="Unified memory available, no pressure detected",
                gpu_utilization_pct=0.0,
            )
        ]

    # -- availability evaluation --------------------------------------------

    def _evaluate_availability(
        self,
        *,
        free_vram_mb: int,
        gpu_util_pct: float,
        processes: list[GPUProcess],
    ) -> tuple[bool, str]:
        """Determine whether a GPU is available for inference.

        Returns ``(available, reason)`` where *reason* is a human-readable
        explanation suitable for logging / headers.

        Rules (in priority order):
        1. If training processes are running -> NOT available
        2. If rendering processes are running -> NOT available
        3. If gaming processes are running -> NOT available
        4. If compute processes are running -> NOT available
        5. If GPU utilization > threshold -> NOT available
        6. If free VRAM < headroom -> NOT available
        7. Otherwise -> available
        """
        # Separate our own inference processes from foreign ones
        blocking_types = {PROCESS_TYPE_TRAINING, PROCESS_TYPE_RENDERING, PROCESS_TYPE_GAMING, PROCESS_TYPE_COMPUTE}

        for proc in processes:
            if proc.process_type == PROCESS_TYPE_INFERENCE_OWN:
                continue  # Our own engine doesn't count as "busy"
            if proc.process_type in blocking_types:
                return False, f"GPU busy: {proc.process_type} process '{proc.name}' (PID {proc.pid})"
            if proc.process_type == PROCESS_TYPE_INFERENCE_FOREIGN:
                return False, f"GPU in use by another inference engine '{proc.name}' (PID {proc.pid}) -- respecting"

        if gpu_util_pct > self.utilization_threshold:
            return (
                False,
                f"GPU compute utilization {gpu_util_pct * 100:.0f}% exceeds threshold ({self.utilization_threshold * 100:.0f}%)",
            )

        if free_vram_mb <= self.vram_headroom_mb:
            return False, f"Free VRAM {free_vram_mb} MB below headroom requirement ({self.vram_headroom_mb} MB)"

        return True, "GPU available for inference"

    def available_vram_for_model(self, availability: GPUAvailability) -> int:
        """Return the VRAM (MB) actually usable for loading a model.

        Subtracts the headroom from free VRAM.  Returns 0 if the GPU is not
        available (respecting user workloads).
        """
        if not availability.available_for_inference:
            return 0
        return max(availability.free_vram_mb - self.vram_headroom_mb, 0)


# ---------------------------------------------------------------------------
# Process introspection helpers (Linux /proc, best-effort on other OS)
# ---------------------------------------------------------------------------


def _get_process_name(pid: int) -> str:
    """Get the process name for *pid* from /proc (Linux) or best-effort."""
    try:
        with open(f"/proc/{pid}/comm") as f:
            return f.read().strip()
    except (FileNotFoundError, PermissionError, OSError):
        return "unknown"


def _get_process_cmdline(pid: int) -> str:
    """Get the full command line for *pid* from /proc (Linux) or best-effort."""
    try:
        with open(f"/proc/{pid}/cmdline") as f:
            # cmdline is NUL-delimited
            return f.read().replace("\x00", " ").strip()
    except (FileNotFoundError, PermissionError, OSError):
        return ""


def _classify_process(name: str, cmdline: str = "") -> str:
    """Classify a GPU process based on name and command line heuristics.

    The classification order matters -- more specific categories are checked
    first.  For Python processes, the command line is essential since ``python``
    alone is ambiguous.
    """
    name_lower = name.lower()
    cmdline_lower = cmdline.lower()
    combined = f"{name_lower} {cmdline_lower}"

    # System processes (display, compositing, drivers) -- check first
    if any(kw in combined for kw in _SYSTEM_KEYWORDS):
        return PROCESS_TYPE_SYSTEM

    # Training (ML training jobs are high-priority user workloads)
    if any(kw in combined for kw in _TRAINING_KEYWORDS):
        return PROCESS_TYPE_TRAINING

    # Rendering (creative workloads)
    if any(kw in combined for kw in _RENDERING_KEYWORDS):
        return PROCESS_TYPE_RENDERING

    # Gaming
    if any(kw in combined for kw in _GAMING_KEYWORDS):
        return PROCESS_TYPE_GAMING

    # General compute (mining, scientific, etc.)
    if any(kw in combined for kw in _COMPUTE_KEYWORDS):
        return PROCESS_TYPE_COMPUTE

    # Inference engines (foreign -- user's own inference setups)
    if any(kw in combined for kw in _INFERENCE_ENGINE_KEYWORDS):
        return PROCESS_TYPE_INFERENCE_FOREIGN

    # Python processes need command-line inspection
    if name_lower in _PYTHON_EXECUTABLES or "python" in name_lower:
        # Check cmdline for specific workload signatures
        if any(kw in cmdline_lower for kw in _TRAINING_KEYWORDS):
            return PROCESS_TYPE_TRAINING
        if any(kw in cmdline_lower for kw in _INFERENCE_ENGINE_KEYWORDS):
            return PROCESS_TYPE_INFERENCE_FOREIGN
        if any(kw in cmdline_lower for kw in _RENDERING_KEYWORDS):
            return PROCESS_TYPE_RENDERING
        if any(kw in cmdline_lower for kw in _COMPUTE_KEYWORDS):
            return PROCESS_TYPE_COMPUTE
        # Python with torch/tensorflow but no training keywords -> could be inference
        if "torch" in cmdline_lower or "tensorflow" in cmdline_lower:
            return PROCESS_TYPE_INFERENCE_FOREIGN

    return PROCESS_TYPE_UNKNOWN


def _is_our_process(pid: int, our_pids: set[int]) -> bool:
    """Check if a process belongs to our managed inference engine.

    Checks:
    1. Explicit PID set (populated at engine start)
    2. Process lineage (is the process a child of our PID?)
    3. Process name matches known engine names we manage
    """
    # Direct PID match
    if pid in our_pids:
        return True

    # Check if any of our PIDs is an ancestor of this process
    current_pid = os.getpid()
    if pid == current_pid:
        return True

    # Check process name for known engine names
    proc_name = _get_process_name(pid).lower()
    if proc_name in _OUR_ENGINE_NAMES:
        # Verify it's actually ours by checking parent chain
        try:
            with open(f"/proc/{pid}/status") as f:
                for line in f:
                    if line.startswith("PPid:"):
                        ppid = int(line.split(":")[1].strip())
                        if ppid in our_pids or ppid == current_pid:
                            return True
                        break
        except (FileNotFoundError, PermissionError, OSError, ValueError):
            pass

    return False
