"""Hardware detection -- GPU, CPU, RAM, disk, and tunnel tool discovery."""

from __future__ import annotations

import asyncio
import logging
import os
import platform
import re
import shutil
import sys
from datetime import datetime, timezone

from llmhosts.discovery.models import GPUInfo, HardwareProfile

logger = logging.getLogger(__name__)


class HardwareDetector:
    """Detects local hardware capabilities for model recommendations.

    All detection methods are *best-effort*: failures are logged at debug level
    and safe defaults are returned so that the caller always gets a valid
    :class:`HardwareProfile`.
    """

    @staticmethod
    async def detect() -> HardwareProfile:
        """Run full hardware detection and return a :class:`HardwareProfile`."""
        # Run independent detections concurrently
        gpus_task = asyncio.create_task(_detect_gpus())
        cpu_model_task = asyncio.create_task(_detect_cpu_model())
        ram_task = asyncio.create_task(_detect_ram())
        tunnel_task = asyncio.create_task(_detect_tunnel_tools())

        gpus = await gpus_task
        cpu_model = await cpu_model_task
        ram_total, ram_available = await ram_task
        tunnel_tools = await tunnel_task

        disk_total, disk_free = _detect_disk()
        os_name, os_version = _detect_os()

        return HardwareProfile(
            gpus=gpus,
            cpu_cores=os.cpu_count() or 1,
            cpu_model=cpu_model,
            ram_total_gb=ram_total,
            ram_available_gb=ram_available,
            disk_total_gb=disk_total,
            disk_free_gb=disk_free,
            os_name=os_name,
            os_version=os_version,
            python_version=platform.python_version(),
            tunnel_tools=tunnel_tools,
            detected_at=datetime.now(tz=timezone.utc),
        )


# ---------------------------------------------------------------------------
# GPU detection
# ---------------------------------------------------------------------------


async def _detect_gpus() -> list[GPUInfo]:
    """Detect NVIDIA and AMD GPUs.  Apple-Silicon detection via sysctl."""
    gpus: list[GPUInfo] = []
    gpus.extend(await _detect_nvidia_gpus())
    gpus.extend(await _detect_amd_gpus())
    if not gpus and sys.platform == "darwin":
        gpus.extend(await _detect_apple_silicon())
    return gpus


async def _detect_nvidia_gpus() -> list[GPUInfo]:
    """Use ``nvidia-smi`` to enumerate NVIDIA GPUs."""
    if not shutil.which("nvidia-smi"):
        return []
    try:
        proc = await asyncio.create_subprocess_exec(
            "nvidia-smi",
            "--query-gpu=name,memory.total,memory.used,driver_version",
            "--format=csv,nounits,noheader",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10.0)
        if proc.returncode != 0:
            logger.debug("nvidia-smi failed (rc=%s): %s", proc.returncode, stderr.decode().strip())
            return []

        gpus: list[GPUInfo] = []
        for line in stdout.decode().strip().splitlines():
            parts = [p.strip() for p in line.split(",")]
            if len(parts) < 4:
                continue
            gpus.append(
                GPUInfo(
                    name=parts[0],
                    vram_total_mb=int(float(parts[1])),
                    vram_used_mb=int(float(parts[2])),
                    driver_version=parts[3],
                    gpu_type="nvidia",
                )
            )
        return gpus
    except (asyncio.TimeoutError, OSError) as exc:
        logger.debug("NVIDIA GPU detection failed: %s", exc)
        return []


async def _detect_amd_gpus() -> list[GPUInfo]:
    """Use ``rocm-smi`` to detect AMD GPUs."""
    if not shutil.which("rocm-smi"):
        return []
    try:
        proc = await asyncio.create_subprocess_exec(
            "rocm-smi",
            "--showmeminfo",
            "vram",
            "--csv",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10.0)
        if proc.returncode != 0:
            logger.debug("rocm-smi failed (rc=%s): %s", proc.returncode, stderr.decode().strip())
            return []

        # Also grab the GPU name via --showproductname
        name_proc = await asyncio.create_subprocess_exec(
            "rocm-smi",
            "--showproductname",
            "--csv",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        name_stdout, _ = await asyncio.wait_for(name_proc.communicate(), timeout=10.0)

        gpu_names: list[str] = []
        for line in name_stdout.decode().strip().splitlines()[1:]:  # skip header
            parts = [p.strip() for p in line.split(",")]
            if parts:
                # First column is device index, second is card series/name
                gpu_names.append(parts[1] if len(parts) > 1 else parts[0])

        # Parse VRAM CSV -- header: "GPU, VRAM Total (B), VRAM Used (B)"
        gpus: list[GPUInfo] = []
        idx = 0
        for line in stdout.decode().strip().splitlines()[1:]:  # skip header
            parts = [p.strip() for p in line.split(",")]
            if len(parts) < 3:
                continue
            vram_total_bytes = int(parts[1]) if parts[1].isdigit() else 0
            vram_used_bytes = int(parts[2]) if parts[2].isdigit() else 0
            gpus.append(
                GPUInfo(
                    name=gpu_names[idx] if idx < len(gpu_names) else f"AMD GPU {idx}",
                    vram_total_mb=vram_total_bytes // (1024 * 1024),
                    vram_used_mb=vram_used_bytes // (1024 * 1024),
                    driver_version="",
                    gpu_type="amd",
                )
            )
            idx += 1
        return gpus
    except (asyncio.TimeoutError, OSError) as exc:
        logger.debug("AMD GPU detection failed: %s", exc)
        return []


async def _detect_apple_silicon() -> list[GPUInfo]:
    """Detect Apple Silicon unified-memory GPU via ``sysctl``."""
    try:
        proc = await asyncio.create_subprocess_exec(
            "sysctl",
            "-n",
            "hw.memsize",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
        if proc.returncode != 0:
            return []
        total_bytes = int(stdout.decode().strip())
        # Apple Silicon shares system RAM as unified memory -- report it as GPU VRAM
        total_mb = total_bytes // (1024 * 1024)

        # Get chip name (e.g. "Apple M2 Pro")
        chip_proc = await asyncio.create_subprocess_exec(
            "sysctl",
            "-n",
            "machdep.cpu.brand_string",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        chip_stdout, _ = await asyncio.wait_for(chip_proc.communicate(), timeout=5.0)
        chip_name = chip_stdout.decode().strip() if chip_proc.returncode == 0 else "Apple Silicon"

        return [
            GPUInfo(
                name=chip_name,
                vram_total_mb=total_mb,
                vram_used_mb=0,  # Not easily available without IOKit
                driver_version="",
                gpu_type="apple_silicon",
            )
        ]
    except (asyncio.TimeoutError, OSError, ValueError) as exc:
        logger.debug("Apple Silicon detection failed: %s", exc)
        return []


# ---------------------------------------------------------------------------
# CPU detection
# ---------------------------------------------------------------------------


async def _detect_cpu_model() -> str:
    """Return a human-readable CPU model string."""
    system = platform.system()
    try:
        if system == "Linux":
            return await _cpu_model_linux()
        if system == "Darwin":
            return await _cpu_model_macos()
        if system == "Windows":
            return await _cpu_model_windows()
    except (asyncio.TimeoutError, OSError, ValueError) as exc:
        logger.debug("CPU model detection failed: %s", exc)
    return platform.processor() or "unknown"


async def _cpu_model_linux() -> str:
    """Parse ``/proc/cpuinfo`` for the model name (async file read via executor)."""
    loop = asyncio.get_running_loop()

    def _read() -> str:
        try:
            with open("/proc/cpuinfo") as f:
                for line in f:
                    if line.startswith("model name"):
                        return line.split(":", 1)[1].strip()
        except FileNotFoundError:
            pass
        return platform.processor() or "unknown"

    return await loop.run_in_executor(None, _read)


async def _cpu_model_windows() -> str:
    """Read CPU name from the Windows registry via ``platform.processor()`` or ``wmic``."""
    # platform.processor() on Windows returns the CPU name from the registry
    name = platform.processor()
    if name and name != "unknown":
        return name
    # Fallback to wmic
    try:
        proc = await asyncio.create_subprocess_exec(
            "wmic",
            "cpu",
            "get",
            "Name",
            "/value",
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE,
        )
        stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
        if proc.returncode == 0:
            for line in stdout.decode().strip().splitlines():
                if line.startswith("Name="):
                    return line.split("=", 1)[1].strip()
    except (asyncio.TimeoutError, OSError):
        pass
    return "unknown"


async def _cpu_model_macos() -> str:
    """Use ``sysctl`` to get the CPU brand string on macOS."""
    proc = await asyncio.create_subprocess_exec(
        "sysctl",
        "-n",
        "machdep.cpu.brand_string",
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
    if proc.returncode == 0:
        return stdout.decode().strip()
    return platform.processor() or "unknown"


# ---------------------------------------------------------------------------
# RAM detection
# ---------------------------------------------------------------------------


async def _detect_ram() -> tuple[float, float]:
    """Return ``(total_gb, available_gb)``."""
    system = platform.system()
    try:
        if system == "Linux":
            return await _ram_linux()
        if system == "Darwin":
            return await _ram_macos()
        if system == "Windows":
            return await _ram_windows()
    except (asyncio.TimeoutError, OSError, ValueError) as exc:
        logger.debug("RAM detection failed: %s", exc)
    return (0.0, 0.0)


async def _ram_linux() -> tuple[float, float]:
    """Parse ``/proc/meminfo`` for total and available RAM."""
    loop = asyncio.get_running_loop()

    def _read() -> tuple[float, float]:
        total_kb = 0
        available_kb = 0
        try:
            with open("/proc/meminfo") as f:
                for line in f:
                    if line.startswith("MemTotal:"):
                        total_kb = int(re.search(r"\d+", line).group())  # type: ignore[union-attr]
                    elif line.startswith("MemAvailable:"):
                        available_kb = int(re.search(r"\d+", line).group())  # type: ignore[union-attr]
        except FileNotFoundError:
            pass
        return (round(total_kb / (1024 * 1024), 2), round(available_kb / (1024 * 1024), 2))

    return await loop.run_in_executor(None, _read)


async def _ram_macos() -> tuple[float, float]:
    """Use ``sysctl`` for total RAM.  Available RAM requires ``vm_stat`` parsing."""
    # Total RAM
    proc = await asyncio.create_subprocess_exec(
        "sysctl",
        "-n",
        "hw.memsize",
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5.0)
    total_bytes = int(stdout.decode().strip()) if proc.returncode == 0 else 0
    total_gb = round(total_bytes / (1024**3), 2)

    # Available RAM (free + inactive pages)
    available_gb = 0.0
    vm_proc = await asyncio.create_subprocess_exec(
        "vm_stat",
        stdout=asyncio.subprocess.PIPE,
        stderr=asyncio.subprocess.PIPE,
    )
    vm_stdout, _ = await asyncio.wait_for(vm_proc.communicate(), timeout=5.0)
    if vm_proc.returncode == 0:
        text = vm_stdout.decode()
        page_size = 16384  # default, parse actual if present
        ps_match = re.search(r"page size of (\d+) bytes", text)
        if ps_match:
            page_size = int(ps_match.group(1))
        free_pages = 0
        inactive_pages = 0
        for line in text.splitlines():
            if "Pages free" in line:
                m = re.search(r"(\d+)", line.split(":")[1])
                free_pages = int(m.group(1)) if m else 0
            elif "Pages inactive" in line:
                m = re.search(r"(\d+)", line.split(":")[1])
                inactive_pages = int(m.group(1)) if m else 0
        available_gb = round((free_pages + inactive_pages) * page_size / (1024**3), 2)

    return (total_gb, available_gb)


async def _ram_windows() -> tuple[float, float]:
    """Use ``wmic`` or ``powershell`` to get RAM on Windows."""
    loop = asyncio.get_running_loop()

    def _read() -> tuple[float, float]:
        import ctypes

        class MEMORYSTATUSEX(ctypes.Structure):
            _fields_ = [
                ("dwLength", ctypes.c_ulong),
                ("dwMemoryLoad", ctypes.c_ulong),
                ("ullTotalPhys", ctypes.c_ulonglong),
                ("ullAvailPhys", ctypes.c_ulonglong),
                ("ullTotalPageFile", ctypes.c_ulonglong),
                ("ullAvailPageFile", ctypes.c_ulonglong),
                ("ullTotalVirtual", ctypes.c_ulonglong),
                ("ullAvailVirtual", ctypes.c_ulonglong),
                ("ullAvailExtendedVirtual", ctypes.c_ulonglong),
            ]

        mem_status = MEMORYSTATUSEX()
        mem_status.dwLength = ctypes.sizeof(MEMORYSTATUSEX)
        ctypes.windll.kernel32.GlobalMemoryStatusEx(ctypes.byref(mem_status))  # type: ignore[attr-defined]
        total_gb = round(mem_status.ullTotalPhys / (1024**3), 2)
        avail_gb = round(mem_status.ullAvailPhys / (1024**3), 2)
        return (total_gb, avail_gb)

    return await loop.run_in_executor(None, _read)


# ---------------------------------------------------------------------------
# Disk detection
# ---------------------------------------------------------------------------


def _detect_disk() -> tuple[float, float]:
    """Return ``(total_gb, free_gb)`` for the root filesystem."""
    try:
        root = "C:\\" if platform.system() == "Windows" else "/"
        usage = shutil.disk_usage(root)
        return (round(usage.total / (1024**3), 2), round(usage.free / (1024**3), 2))
    except OSError as exc:
        logger.debug("Disk detection failed: %s", exc)
        return (0.0, 0.0)


# ---------------------------------------------------------------------------
# OS detection
# ---------------------------------------------------------------------------


def _detect_os() -> tuple[str, str]:
    """Return ``(os_name, os_version)``."""
    return (platform.system(), platform.release())


# ---------------------------------------------------------------------------
# Tunnel tool detection
# ---------------------------------------------------------------------------


async def _detect_tunnel_tools() -> list[str]:
    """Check if the LLMHosts relay extension is available."""
    from llmhosts.tunnel.relay import RELAY_AVAILABLE

    return ["relay_core"] if RELAY_AVAILABLE else []
