"""Health monitoring utilities for TaskRunner.
Collects periodic process/system metrics to assess runner health.
"""

# Question: should this just be a task? or its own thing?
from __future__ import annotations

import shutil
import threading
import time
import os
import sys
from typing import Any
from collections.abc import Callable

from hil_testbench.run.task_logger import TaskLogger


try:
    import psutil  # hil: allow-lazy - optional dependency  # type: ignore
except ImportError:  # pragma: no cover - fallback if psutil missing
    psutil = None  # type: ignore


try:
    import psutil  # hil: allow-lazy - optional dependency  # type: ignore
except ImportError:  # pragma: no cover - fallback if psutil missing
    psutil = None  # type: ignore


class HealthMonitor:
    """Background thread that periodically logs health metrics.

    Metrics (if psutil available):
        - process_cpu_percent
        - system_cpu_percent
        - process_memory_rss_bytes
        - process_memory_percent
        - thread_count
        - open_fds (Unix only / if available)
        - disk_free_gb (log directory)
        - running_tasks (callback provided)

    Thresholds:
        - cpu_threshold: CPU % to trigger warning (default 80)
        - memory_threshold: Memory % to trigger warning (default 85)
        - disk_threshold_gb: Free disk space in GB to trigger warning (default 10)

    Fallback (no psutil):
        - thread_count (approx)
        - running_tasks
    """

    def __init__(
        self,
        logger: TaskLogger,
        interval: int = 600,
        running_tasks_supplier: Callable[[], int] | None = None,
        stop_event: threading.Event | None = None,
        log_directory: str | None = None,
        cpu_threshold: float = 80.0,
        memory_threshold: float = 85.0,
        disk_threshold_gb: float = 10.0,
    ):
        self._logger = logger
        self._interval = max(5, interval)  # safeguard minimal interval
        self._running_tasks_supplier = running_tasks_supplier or (lambda: -1)
        self._stop_event = stop_event or threading.Event()
        self._thread: threading.Thread | None = None
        self._started = False
        self._pid = os.getpid()
        self._proc = psutil.Process(self._pid) if psutil else None  # type: ignore
        self._log_directory = log_directory
        self._cpu_threshold = cpu_threshold
        self._memory_threshold = memory_threshold
        self._disk_threshold_gb = disk_threshold_gb
        self._active_warnings: set[str] = set()
        self._process_registry: dict[int, str] = {}  # pid -> description
        self._thread_registry: dict[int, str] = {}  # thread_id -> description

    def start(self):
        """Start health monitoring thread."""
        if self._started:
            return
        self._started = True
        self._thread = threading.Thread(
            target=self._run, name="HealthMonitor", daemon=True
        )
        # Log thread creation
        self._logger.log_debug(
            "thread_created",
            thread_name="HealthMonitor",
            daemon=True,
            purpose="system health monitoring",
        )
        self._thread.start()
        self._logger.log_info(
            "health_monitor_started",
            None,
            interval_seconds=self._interval,
        )

    def stop(self):
        """Stop health monitoring thread."""
        if not self._started:
            return
        self._stop_event.set()
        if self._thread:
            self._thread.join(timeout=2)
            # Log thread termination
            self._logger.log_debug(
                "thread_terminated",
                thread_name="HealthMonitor",
                alive=self._thread.is_alive(),
            )

        # Check for leaked processes/threads
        if self._process_registry or self._thread_registry:
            leaked_data = {}
            if self._process_registry:
                leaked_data["leaked_processes"] = list(self._process_registry.values())
            if self._thread_registry:
                leaked_data["leaked_threads"] = list(self._thread_registry.values())
            self._logger.log_warning(
                "resource_leak_detected",
                None,
                **leaked_data,
            )

        self._logger.log_info("health_monitor_stopped", None)
        self._started = False

    def register_process(self, pid: int, description: str):
        """Register a process for leak tracking."""
        self._process_registry[pid] = description

    def unregister_process(self, pid: int):
        """Unregister a process (normal cleanup)."""
        self._process_registry.pop(pid, None)

    def register_thread(self, thread_id: int, description: str):
        """Register a thread for leak tracking."""
        self._thread_registry[thread_id] = description

    def unregister_thread(self, thread_id: int):
        """Unregister a thread (normal cleanup)."""
        self._thread_registry.pop(thread_id, None)

    def get_active_warnings(self) -> list[dict[str, Any]]:
        """Get list of active warnings for display."""
        warnings = []
        for warning in self._active_warnings:
            warnings.append({"type": warning, "active": True})
        return warnings

    def get_thresholds(self) -> dict[str, float]:
        """Get configured threshold values."""
        return {
            "cpu": self._cpu_threshold,
            "memory": self._memory_threshold,
            "disk_gb": self._disk_threshold_gb,
        }

    def get_final_summary(self) -> dict[str, Any] | None:
        """Get final health metrics snapshot for shutdown display."""
        if not self._started:
            return None
        try:
            return self._collect_metrics()
        except Exception:  # pylint: disable=broad-except
            return None

    def _collect_metrics(self) -> dict[str, Any]:
        data: dict[str, Any] = {}
        data["running_tasks"] = self._running_tasks_supplier()

        # Disk space check
        if self._log_directory:
            try:
                disk_usage = shutil.disk_usage(self._log_directory)
                disk_free_gb = disk_usage.free / (1024**3)
                data["disk_free_gb"] = round(disk_free_gb, 2)

                # Check disk threshold
                if disk_free_gb < self._disk_threshold_gb:
                    if "disk_low" not in self._active_warnings:
                        self._active_warnings.add("disk_low")
                        self._logger.log_warning(
                            "threshold_exceeded",
                            None,
                            threshold_type="disk_space",
                            free_gb=round(disk_free_gb, 2),
                            threshold_gb=self._disk_threshold_gb,
                        )
                else:
                    self._active_warnings.discard("disk_low")
            except (OSError, RuntimeError):
                pass

        if psutil and self._proc:
            try:
                # CPU percent: first call returns 0.0, subsequent gives actual. Call once ahead?
                cpu_proc = self._proc.cpu_percent(
                    interval=None
                )  # percentage since last call
                cpu_sys = psutil.cpu_percent(interval=None)  # system-wide

                # Some psutil builds use a per-process cache set by oneshot(); ensure presence
                if not hasattr(self._proc, "_cache"):
                    try:
                        setattr(self._proc, "_cache", {})  # best-effort guard
                    except Exception:
                        pass

                try:
                    mem_info = self._proc.memory_info()
                except AttributeError:
                    # Recreate Process handle and retry once
                    self._proc = psutil.Process(self._pid)
                    mem_info = self._proc.memory_info()

                try:
                    mem_percent = self._proc.memory_percent()
                except (RuntimeError, OSError, AttributeError):  # pragma: no cover
                    mem_percent = None

                data.update(
                    {
                        "process_cpu_percent": round(cpu_proc, 2),
                        "system_cpu_percent": round(cpu_sys, 2),
                        "process_memory_rss_bytes": getattr(mem_info, "rss", 0),
                        "process_memory_rss_mb": round(
                            getattr(mem_info, "rss", 0) / (1024**2), 2
                        ),
                        "process_memory_percent": (
                            round(mem_percent, 2) if mem_percent else None
                        ),
                        "thread_count": self._proc.num_threads(),
                    }
                )
            except Exception as ex:  # pylint: disable=broad-except
                # Best-effort metrics; do not let psutil glitches break shutdown
                self._logger.log_warning(
                    "health_monitor_sampling_error",
                    None,
                    error=str(ex),
                )
                # Provide minimal fallback
                try:
                    data.setdefault("thread_count", self._proc.num_threads())
                except Exception:
                    pass

            # Check CPU threshold (only if successfully sampled)
            if (
                "process_cpu_percent" in data
                and data["process_cpu_percent"] is not None
                and data["process_cpu_percent"] > self._cpu_threshold
            ):
                if "cpu_high" not in self._active_warnings:
                    self._active_warnings.add("cpu_high")
                    self._logger.log_warning(
                        "threshold_exceeded",
                        None,
                        threshold_type="cpu",
                        cpu_percent=data["process_cpu_percent"],
                        threshold=self._cpu_threshold,
                    )
            else:
                self._active_warnings.discard("cpu_high")

            # Check memory threshold (only if successfully sampled)
            if (
                "process_memory_percent" in data
                and data["process_memory_percent"] is not None
                and data["process_memory_percent"] > self._memory_threshold
            ):
                if "memory_high" not in self._active_warnings:
                    self._active_warnings.add("memory_high")
                    self._logger.log_warning(
                        "threshold_exceeded",
                        None,
                        threshold_type="memory",
                        memory_percent=data["process_memory_percent"],
                        threshold=self._memory_threshold,
                    )
            else:
                self._active_warnings.discard("memory_high")

            # open file descriptors (Unix)
            # Open file descriptors (Unix only)
            try:
                if sys.platform != "win32" and hasattr(self._proc, "open_files"):
                    # Approximate via count of open files list
                    data["open_fds"] = len(self._proc.open_files())
            except (OSError, RuntimeError):  # pragma: no cover
                pass
        else:
            # Fallback minimal stats
            data.update({"thread_count": threading.active_count()})
        return data

    def _run(self):
        # Prime psutil CPU percent for meaningful next sample
        if psutil and self._proc:
            self._proc.cpu_percent(interval=None)
            psutil.cpu_percent(interval=None)
        next_time = time.time() + self._interval
        while not self._stop_event.is_set():
            now = time.time()
            if now >= next_time:
                metrics = self._collect_metrics()
                # Log structured health snapshot
                self._logger.log_info(
                    "health_snapshot",
                    None,
                    **metrics,
                )
                next_time = now + self._interval
            time.sleep(1)


__all__ = ["HealthMonitor"]
