"""Diagnostics engine — broker preconditions as structured findings.

The single source of truth for the `mgf-hrb doctor` check list. Both
the CLI (`mgf.hrb.cli._doctor`) and the SDK (`Client.doctor`) consume
the same `run_all()` so the CLI and any future operator dashboard
report identical results, in the same order, with the same codes —
no logic duplication, no drift.

**Why this lives in Python (not as a wire op).** Every check answers
a question from the CALLER's perspective: can THIS process reach the
socket, can THIS user read `/sys/bus/pci/devices`, is THIS host's
IOMMU populated? The broker can't answer those — only the calling
process can. The one check that needs broker introspection
(``backend_capabilities``) runs the normal ``capabilities()`` wire
op. So the shared engine is a Python module, not a Zig op (closes
FEEDBACK TOOLS-02; supersedes the paper's wire-op suggestion with
the correct client-side shape).

Consumer surface:

.. code-block:: python

    from mgf.hrb import diagnostics, Client

    # No broker required — runs the local-only checks:
    findings = diagnostics.run_all("/tmp/mgf-hrb.sock")

    # Or via a live client (also runs the broker-side capability check):
    with Client.connect("/tmp/mgf-hrb.sock") as c:
        findings = c.doctor()

Each :class:`DoctorFinding` carries a stable ``code`` (e.g.
``"SOCKET_REACHABLE"``, ``"IOMMU_PRESENT"``) so a GUI can match on
identity and render its own prose. The ``severity`` is a four-way
enum (``OK`` / ``WARN`` / ``FAIL`` / ``DEFERRED``); ``DEFERRED``
names a check the engine knows about but cannot yet run (rule
template not installed, future-worker dependency).
"""

from __future__ import annotations

import logging
import os
import socket
from dataclasses import dataclass
from enum import StrEnum
from pathlib import Path
from typing import TYPE_CHECKING

# I1 (review wave 2) — diagnostics module previously had no logger
# despite running several try/except blocks that swallow exceptions
# silently (the engine's contract is "every check returns a Finding,
# never raises"). The logger is now available for those swallowed-
# exception sites to drop a forensic DEBUG-level line so `-v` traces
# don't lose the root cause behind a clean Finding.
logger = logging.getLogger(__name__)

if TYPE_CHECKING:
    from mgf.hrb._client import Client

__all__ = [
    "Category",
    "DoctorFinding",
    "Severity",
    "check_backend_capabilities",
    "check_iommu_groups",
    "check_pci_sysfs",
    "check_socket",
    "check_udev_rule",
    "check_usb_sysfs",
    "check_usbfs_devnode",
    "check_usbfs_openable",
    "check_vfio_device",
    "run_all",
]


class Severity(StrEnum):
    """Four-way severity. ``OK`` / ``WARN`` / ``FAIL`` is the paper's
    spec (FEEDBACK TOOLS-02); ``DEFERRED`` names a check the engine
    knows about but cannot yet run (e.g. udev-rule check before the
    rule template ships). Consumers that only care about fail-pass
    can collapse ``DEFERRED`` into ``WARN`` (the doctor CLI does
    this for its exit-code calculation)."""

    OK = "ok"
    WARN = "warn"
    FAIL = "fail"
    DEFERRED = "deferred"


class Category(StrEnum):
    """Grouping for the operator-facing report. A renderer (CLI today,
    GUI tomorrow) puts each category under its own header so the
    substrate / hardware / broker concerns are visually separated."""

    SUBSTRATE = "Substrate (sysfs + /dev nodes)"
    HARDWARE = "Hardware policy (IOMMU, group membership, udev)"
    BROKER = "Broker (socket + cap table)"


@dataclass(frozen=True, slots=True)
class DoctorFinding:
    """One structured doctor result (FEEDBACK TOOLS-02).

    Carries a stable ``code`` consumers can match on, a four-way
    ``severity``, and the operator-facing prose. The dataclass is
    frozen so a finding can be safely shared across threads /
    cached / pickled.

    Fields:
      * ``code`` — stable identifier (e.g. ``"SOCKET_REACHABLE"``,
        ``"IOMMU_PRESENT"``). The contract: code values are stable
        across SDK MINORs; renderers may match on them. New codes
        may appear; existing codes do not change spelling.
      * ``severity`` — ``OK`` / ``WARN`` / ``FAIL`` / ``DEFERRED``.
      * ``category`` — grouping for the renderer.
      * ``summary`` — one-line human-readable headline.
      * ``detail`` — multi-line, optional; the body of the finding.
      * ``why`` — one-line "why this matters" for non-obvious checks.
      * ``remediation`` — copy-pastable fix line, or ``None``.
    """

    code: str
    severity: Severity
    category: Category
    summary: str
    detail: str
    why: str | None = None
    remediation: str | None = None


# === individual checks ===================================================
#
# Each check is a small pure-ish function that returns a single
# DoctorFinding. No raising — every failure mode is a finding so the
# renderer can show ALL the news, not stop at the first bad one.


def check_socket(socket_path: str) -> DoctorFinding:
    """Is the broker socket reachable from this process?"""
    if not Path(socket_path).exists():
        return DoctorFinding(
            code="SOCKET_REACHABLE",
            severity=Severity.FAIL,
            category=Category.BROKER,
            summary="broker socket",
            detail=f"no socket at {socket_path}.",
            why="without a live broker, the SDK can't talk to the kernel side.",
            remediation=(
                "start the broker: `cd hrb-code && zig build && ./zig-out/bin/mgf-hrb-server`"
            ),
        )
    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    try:
        s.connect(socket_path)
        s.close()
    except OSError as exc:
        return DoctorFinding(
            code="SOCKET_REACHABLE",
            severity=Severity.FAIL,
            category=Category.BROKER,
            summary="broker socket",
            detail=f"socket exists at {socket_path} but connect() failed: {exc}",
            why=("stale socket files survive a crashed broker; the next start must clean them up."),
            remediation=("ensure the broker process is running and the socket isn't a stale file"),
        )
    return DoctorFinding(
        code="SOCKET_REACHABLE",
        severity=Severity.OK,
        category=Category.BROKER,
        summary="broker socket",
        detail=f"connected to {socket_path}.",
    )


def check_pci_sysfs() -> DoctorFinding:
    """Can this process read the PCI sysfs root?"""
    return _check_path_readable(
        code="PCI_SYSFS_READABLE",
        summary="PCI sysfs",
        path="/sys/bus/pci/devices",
        category=Category.SUBSTRATE,
        why=(
            "PCI enumeration reads this dir directly; absence means the PCI "
            "worker will return empty without any error."
        ),
        remediation=(
            "ensure /sys is mounted; on container hosts this often means "
            "--privileged or --device=..."
        ),
    )


def check_usb_sysfs() -> DoctorFinding:
    """Can this process read the USB sysfs root?"""
    return _check_path_readable(
        code="USB_SYSFS_READABLE",
        summary="USB sysfs",
        path="/sys/bus/usb/devices",
        category=Category.SUBSTRATE,
        why=(
            "USB enumeration walks this dir; absence means no USB devices "
            "will be discovered even if some are plugged in."
        ),
        remediation=("ensure /sys is mounted; the USB worker will need this readable"),
    )


def check_vfio_device() -> DoctorFinding:
    """Is /dev/vfio/vfio present? (Required for VFIO acquire path.)"""
    if Path("/dev/vfio/vfio").exists():
        return DoctorFinding(
            code="VFIO_DEVICE_PRESENT",
            severity=Severity.OK,
            category=Category.HARDWARE,
            summary="VFIO device",
            detail=("/dev/vfio/vfio is present; real DMA-capable acquire path is exerciseable."),
            why=(
                "VFIO is the kernel ABI mgf-hrb uses to bind a device for "
                "passthrough; without it acquire honestly returns Unsupported."
            ),
        )
    return DoctorFinding(
        code="VFIO_DEVICE_PRESENT",
        severity=Severity.WARN,
        category=Category.HARDWARE,
        summary="VFIO device",
        detail="/dev/vfio/vfio not present.",
        why=(
            "acquire returns Unsupported (honest, G2) on this host. Read-side "
            "ops (scan, info) still work — only claim / raw need VFIO."
        ),
        remediation=(
            "load vfio modules (`sudo modprobe vfio-pci`) and ensure IOMMU is "
            "enabled in the kernel cmdline (intel_iommu=on / amd_iommu=on)"
        ),
    )


def check_iommu_groups() -> DoctorFinding:
    """Is the IOMMU populated? (FEEDBACK LINUX-01.)"""
    groups = Path("/sys/kernel/iommu_groups")
    if not groups.is_dir():
        return DoctorFinding(
            code="IOMMU_PRESENT",
            severity=Severity.WARN,
            category=Category.HARDWARE,
            summary="IOMMU groups",
            detail=f"{groups} is not present — host has no IOMMU.",
            why=(
                "DMA-capable passthrough is structurally unsafe on a "
                "no-IOMMU host (FEEDBACK LINUX-01) — the device could DMA "
                "anywhere in host memory."
            ),
            remediation=(
                "enable IOMMU in BIOS (VT-d for Intel, AMD-Vi for AMD) and the "
                "kernel cmdline (intel_iommu=on / amd_iommu=on)"
            ),
        )
    try:
        n_groups = sum(1 for _ in groups.iterdir())
    except OSError as exc:
        return DoctorFinding(
            code="IOMMU_PRESENT",
            severity=Severity.FAIL,
            category=Category.HARDWARE,
            summary="IOMMU groups",
            detail=f"{groups} exists but unreadable: {exc}",
            why="readability is required to map devices to IOMMU groups.",
        )
    if n_groups == 0:
        return DoctorFinding(
            code="IOMMU_PRESENT",
            severity=Severity.WARN,
            category=Category.HARDWARE,
            summary="IOMMU groups",
            detail=f"{groups} exists but is empty.",
            why="IOMMU appears off — confirm BIOS + kernel cmdline.",
            remediation=("verify BIOS + kernel cmdline (intel_iommu=on / amd_iommu=on)"),
        )
    return DoctorFinding(
        code="IOMMU_PRESENT",
        severity=Severity.OK,
        category=Category.HARDWARE,
        summary="IOMMU groups",
        detail=f"{n_groups} IOMMU group(s) populated.",
    )


def check_usbfs_devnode() -> DoctorFinding:
    """Is /dev/bus/usb populated? (R3 — TOOLS-01 USB substrate row.)

    The USB worker's claim path opens `/dev/bus/usb/<bus>/<dev>` via
    USBDEVFS. Without the device-node tree, scan-via-sysfs still
    works (USB_SYSFS_READABLE covers that) but claim returns
    NotFound for every URN. Distinct from USB_SYSFS_READABLE: sysfs
    is the enumeration surface; usbfs is the claim/raw surface.
    """
    devroot = Path("/dev/bus/usb")
    if not devroot.is_dir():
        return DoctorFinding(
            code="USBFS_DEVNODE_PRESENT",
            severity=Severity.WARN,
            category=Category.SUBSTRATE,
            summary="USB device nodes",
            detail=f"{devroot} not present.",
            why=(
                "the USB worker's claim path opens /dev/bus/usb/<bus>/<dev>; "
                "without this tree, claim returns NotFound for every URN."
            ),
            remediation=(
                "mount devtmpfs (modern distros do this at boot); on "
                "containers ensure /dev is bind-mounted from the host"
            ),
        )
    try:
        bus_dirs = [d for d in devroot.iterdir() if d.is_dir()]
    except OSError as exc:
        return DoctorFinding(
            code="USBFS_DEVNODE_PRESENT",
            severity=Severity.WARN,
            category=Category.SUBSTRATE,
            summary="USB device nodes",
            detail=f"{devroot} exists but iterdir failed: {exc}",
            why="the USB worker enumerates bus directories before opening.",
        )
    if not bus_dirs:
        # devtmpfs mounted but no USB controllers populated. The
        # worker honestly returns zero devices; not an error.
        return DoctorFinding(
            code="USBFS_DEVNODE_PRESENT",
            severity=Severity.OK,
            category=Category.SUBSTRATE,
            summary="USB device nodes",
            detail=f"{devroot} present; no USB buses enumerated yet.",
        )
    return DoctorFinding(
        code="USBFS_DEVNODE_PRESENT",
        severity=Severity.OK,
        category=Category.SUBSTRATE,
        summary="USB device nodes",
        detail=f"{devroot} present; {len(bus_dirs)} USB bus(es) enumerated.",
    )


def check_usbfs_openable() -> DoctorFinding:
    """Can the current process open() a USBDEVFS device node?
    (R3 — TOOLS-01 USB substrate row.)

    The USB worker's claim path calls ``open(O_RDWR)`` on a USB
    device node, then ``USBDEVFS_CLAIMINTERFACE`` via ioctl. The
    ``open()`` is the very first kernel call — a permissions
    failure here is exactly what operators hit when no udev rule
    is installed (EACCES). This check picks the first available
    device node and tries the open; the device fd is closed
    immediately, no claim is attempted.

    Severities:

    * ``OK`` — open succeeded (root or udev-rule-equipped host).
    * ``WARN`` — open returned EACCES; remediation points at the
      udev-rule install.
    * ``WARN`` — open failed with another OSError; the error is
      surfaced in ``detail``.
    * ``DEFERRED`` — ``/dev/bus/usb`` is absent or has no devices
      to test against (USBFS_DEVNODE_PRESENT already covered that).
    """
    devroot = Path("/dev/bus/usb")
    if not devroot.is_dir():
        return DoctorFinding(
            code="USBFS_DEVNODE_OPENABLE",
            severity=Severity.DEFERRED,
            category=Category.SUBSTRATE,
            summary="USBDEVFS open",
            detail="/dev/bus/usb missing — see USBFS_DEVNODE_PRESENT.",
            why=(
                "without the device-node tree there's nothing to open; "
                "the upstream check covers this case."
            ),
        )
    candidate: Path | None = None
    try:
        for bus_dir in sorted(devroot.iterdir()):
            if not bus_dir.is_dir():
                continue
            for dev_file in sorted(bus_dir.iterdir()):
                if dev_file.is_dir():
                    continue
                candidate = dev_file
                break
            if candidate is not None:
                break
    except OSError as exc:
        return DoctorFinding(
            code="USBFS_DEVNODE_OPENABLE",
            severity=Severity.WARN,
            category=Category.SUBSTRATE,
            summary="USBDEVFS open",
            detail=f"could not enumerate {devroot}: {exc}",
            why="the open check needs to find a device node to probe.",
        )
    if candidate is None:
        return DoctorFinding(
            code="USBFS_DEVNODE_OPENABLE",
            severity=Severity.DEFERRED,
            category=Category.SUBSTRATE,
            summary="USBDEVFS open",
            detail="no USB devices to test against (empty /dev/bus/usb tree).",
            why=(
                "the open check needs a real device node to probe; plug "
                "in any USB device or re-run the check later."
            ),
        )
    try:
        # Mirror the worker's call shape exactly: O_RDWR matches
        # what `linux_usb.zig:vAcquire` opens with. O_NONBLOCK
        # guards against any theoretical blocking open(); the fd
        # is closed immediately, no I/O is issued.
        fd = os.open(str(candidate), os.O_RDWR | os.O_NONBLOCK)
        os.close(fd)
    except PermissionError:
        return DoctorFinding(
            code="USBFS_DEVNODE_OPENABLE",
            severity=Severity.WARN,
            category=Category.SUBSTRATE,
            summary="USBDEVFS open",
            detail=(
                f"open({candidate}) returned EACCES — the USB worker's "
                "claim path will surface as NotPermitted for non-root "
                "callers."
            ),
            why=(
                "claim opens /dev/bus/usb/<bus>/<dev> O_RDWR; without an "
                "appropriate udev rule the kernel returns EACCES to "
                "non-root callers."
            ),
            remediation=(
                "install the mgf-hrb USB udev rule (see "
                "hrb-design/mgf-hrb-usb-worker.md §7) or run as root"
            ),
        )
    except OSError as exc:
        return DoctorFinding(
            code="USBFS_DEVNODE_OPENABLE",
            severity=Severity.WARN,
            category=Category.SUBSTRATE,
            summary="USBDEVFS open",
            detail=f"open({candidate}) failed: {exc}",
            why="claim opens the device node before the first ioctl.",
        )
    return DoctorFinding(
        code="USBFS_DEVNODE_OPENABLE",
        severity=Severity.OK,
        category=Category.SUBSTRATE,
        summary="USBDEVFS open",
        detail=(f"open({candidate}) succeeded — the worker's claim path is reachable."),
    )


_UDEV_RULE_BASENAME = "70-mgf-hrb.rules"
_UDEV_RULE_PATHS: tuple[str, ...] = (
    # Operator-installed (preferred — survives package upgrades).
    "/etc/udev/rules.d/" + _UDEV_RULE_BASENAME,
    # Distro-packaged.
    "/usr/lib/udev/rules.d/" + _UDEV_RULE_BASENAME,
    # Runtime (e.g. installed via `udevadm control --reload`).
    "/run/udev/rules.d/" + _UDEV_RULE_BASENAME,
)


def check_udev_rule() -> DoctorFinding:
    """Is the mgf-hrb USB udev rule installed?

    R-USB-PKG (2026-05-27) — was DEFERRED until the rule template
    shipped. The template now lives at
    ``packaging/udev/70-mgf-hrb.rules``; this check looks for it at
    the three canonical udev rule paths (operator-installed, distro-
    packaged, runtime).

    Severities:

    * ``OK`` — rule file present at one of the canonical paths.
    * ``WARN`` — rule file absent. Claim still works as root but
      returns ``NotPermitted`` for non-root callers.
    """
    found_at: str | None = None
    for path in _UDEV_RULE_PATHS:
        if Path(path).is_file():
            found_at = path
            break
    if found_at is not None:
        return DoctorFinding(
            code="UDEV_RULE_INSTALLED",
            severity=Severity.OK,
            category=Category.HARDWARE,
            summary="mgf-hrb udev rule",
            detail=f"rule installed at {found_at}.",
        )
    return DoctorFinding(
        code="UDEV_RULE_INSTALLED",
        severity=Severity.WARN,
        category=Category.HARDWARE,
        summary="mgf-hrb udev rule",
        detail=(f"no {_UDEV_RULE_BASENAME} found in any of: {', '.join(_UDEV_RULE_PATHS)}."),
        why=(
            "without the rule, non-root operators can't open "
            "/dev/bus/usb/* — claim then returns NotPermitted "
            "instead of working."
        ),
        remediation=(
            "sudo install -m 0644 packaging/udev/70-mgf-hrb.rules "
            "/etc/udev/rules.d/ && "
            "sudo udevadm control --reload-rules && sudo udevadm trigger"
        ),
    )


def check_backend_capabilities(
    client: Client | None,
    *,
    socket_reachable: bool,
) -> DoctorFinding:
    """Ask the broker which workers exist + each backend's reported mode.

    When ``client`` is ``None`` (caller has no live connection) OR the
    socket is unreachable, the check returns ``DEFERRED`` rather than
    attempting a connection — the caller already has a SOCKET finding
    that tells the operator what to do.
    """
    if not socket_reachable or client is None:
        return DoctorFinding(
            code="BACKEND_CAPABILITIES",
            severity=Severity.DEFERRED,
            category=Category.BROKER,
            summary="backend capabilities",
            detail=("skipped — broker socket not reachable. Fix the socket check first."),
            why=(
                "the capabilities query is how an operator learns which buses "
                "are wired and what mode each is operating in (full / "
                "read_only / degraded_no_iommu / degraded_other)."
            ),
        )
    try:
        backends = client.capabilities()
    except Exception as exc:  # any failure becomes one finding (G2)
        return DoctorFinding(
            code="BACKEND_CAPABILITIES",
            severity=Severity.FAIL,
            category=Category.BROKER,
            summary="backend capabilities",
            detail=f"capabilities() raised: {type(exc).__name__}: {exc}",
            why=(
                "the broker is reachable but rejected the introspection query; "
                "this usually points at a wire-protocol skew."
            ),
            remediation=(
                "ensure mgf-hrb-server and the SDK are built from the same "
                "version (the wire is versioned-together pre-1.0)"
            ),
        )
    if not backends:
        return DoctorFinding(
            code="BACKEND_CAPABILITIES",
            severity=Severity.WARN,
            category=Category.BROKER,
            summary="backend capabilities",
            detail="broker reports zero backends — no workers are wired.",
            why=(
                "without backends the broker can serve no URNs; scan / claim "
                "will return NotFound for everything."
            ),
        )
    # Operational mode roll-up: full == OK; anything else → WARN.
    # `backends` is a list of typed BackendCapability records
    # (FEEDBACK SDK-05); .mode is `str | None` ("full" / "read_only"
    # / "degraded_*", or None on older brokers that didn't report it).
    degraded = [b for b in backends if b.mode and b.mode != "full"]
    if degraded:
        modes = ", ".join(f"{b.scope_prefix}={b.mode}" for b in degraded)
        return DoctorFinding(
            code="BACKEND_CAPABILITIES",
            severity=Severity.WARN,
            category=Category.BROKER,
            summary="backend capabilities",
            detail=(f"{len(backends)} backend(s); one or more degraded: {modes}."),
            why=(
                "a degraded backend still serves read-side ops (scan, info) "
                "but can't claim or grant raw access. The mode names the "
                "missing precondition (e.g. degraded_no_iommu)."
            ),
        )
    return DoctorFinding(
        code="BACKEND_CAPABILITIES",
        severity=Severity.OK,
        category=Category.BROKER,
        summary="backend capabilities",
        detail=(f"{len(backends)} backend(s) reporting; all in `full` mode."),
    )


def run_all(
    socket_path: str,
    *,
    client: Client | None = None,
) -> list[DoctorFinding]:
    """Run the full doctor check suite. Returns one finding per check.

    When ``client`` is provided the broker-side checks (today: just
    ``BACKEND_CAPABILITIES``) hit the live broker; otherwise they
    return ``DEFERRED``. Either way the LOCAL checks (socket reachable
    from this process, sysfs paths, IOMMU presence) always run.

    The finding order is stable and matches the CLI's render order so
    a GUI rendering top-to-bottom sees the same story an operator
    sees in the terminal.
    """
    socket_finding = check_socket(socket_path)
    socket_ok = socket_finding.severity == Severity.OK
    return [
        socket_finding,
        check_pci_sysfs(),
        check_usb_sysfs(),
        check_usbfs_devnode(),
        check_usbfs_openable(),
        check_vfio_device(),
        check_iommu_groups(),
        check_udev_rule(),
        check_backend_capabilities(client, socket_reachable=socket_ok),
    ]


# --- helpers --------------------------------------------------------------


def _check_path_readable(
    *,
    code: str,
    summary: str,
    path: str,
    category: Category,
    why: str,
    remediation: str | None = None,
) -> DoctorFinding:
    p = Path(path)
    if not p.exists():
        return DoctorFinding(
            code=code,
            severity=Severity.WARN,
            category=category,
            summary=summary,
            detail=f"{path} not present.",
            why=why,
            remediation=remediation,
        )
    if not os.access(p, os.R_OK):
        return DoctorFinding(
            code=code,
            severity=Severity.WARN,
            category=category,
            summary=summary,
            detail=f"{path} not readable by current user.",
            why=why,
            remediation=remediation,
        )
    return DoctorFinding(
        code=code,
        severity=Severity.OK,
        category=category,
        summary=summary,
        detail=f"{path} readable.",
    )
