"""Diagnostic capture for failed / hung / crashed tests.

Every function here returns a `str` (possibly empty) and NEVER
raises. The supervisor calls these from the failure-handling path;
a raise here would defeat the whole point of robust diagnostics.

Each capture is best-effort and documents WHY it can fail. A
contributor reading a failure bundle should see either real
diagnostic data or an explicit `(capture failed: <reason>)` line —
never a silent gap.

Federation reuse: the daemon_state() probe is project-specific.
Adapter projects with a daemon set the binary name in ONE of two
ways (in order of precedence):

  1. `MGF_TEST_SUPERVISOR_DAEMON_PROCESS_NAME` env var (per-run
     override; typical for adapter projects pinning the value in
     `.envrc` / `conftest.py` / CI). Symmetric with the existing
     `MGF_TEST_SUPERVISOR_REPO_ROOT` pattern — no code patch
     needed in the supervisor library; the daemon-bearing
     project owns the value.
  2. `DAEMON_PROCESS_NAME` module constant below (None by default;
     edited per project when an env var is awkward to thread).

Both `None` / empty / unset → the probe is a no-op (returns the
empty string), so projects without a daemon get a quiet
`(empty)` section in failure bundles. Adopters with a daemon get
a `pgrep`-driven snapshot of pid + wchan + thread count for
every match.

Adapted for mgf-common 2026-05-25 — `DAEMON_PROCESS_NAME = None`
(no daemon); ziglang fingerprint dropped (no Zig toolchain);
`mgf.hrb` import → `mgf.common` import.

Env-var override added 2026-05-26 (v0.1.1) so daemon-bearing
projects like mgf-hrb can adopt the library without forking
`DAEMON_PROCESS_NAME`. Symmetric shape with
`MGF_TEST_SUPERVISOR_REPO_ROOT`.
"""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from pathlib import Path

# The project's daemon binary name (None if no daemon).
# Edit per project OR override at run-time via the
# `MGF_TEST_SUPERVISOR_DAEMON_PROCESS_NAME` env var (preferred for
# adopter projects that consume this library unmodified —
# see `_daemon_process_name()` below).
#
# The probe greps for this string in process command lines, so a
# partial match (e.g., "mgf-common") would match too many unrelated
# processes — use the full binary name (e.g., "mgf-hrb-server").
#
# mgf-common ships no daemon, so the probe is a no-op by default;
# the constant + branch survive so the supervisor's signature is
# stable across federation projects.
DAEMON_PROCESS_NAME: str | None = None


def _daemon_process_name() -> str | None:
    """Resolve the daemon binary name from env-var → module constant.

    Read at call time (not at import) so adapter projects can set the
    env var in a session-startup hook (`.envrc`, `conftest.py`,
    CI step) and the supervisor picks it up without re-import gymnastics.

    Empty string in the env is treated as "not set" — same as unset —
    so daemon-less projects that defensively export the var with no
    value don't accidentally enable the probe with a bogus name.
    """
    override = os.environ.get("MGF_TEST_SUPERVISOR_DAEMON_PROCESS_NAME", "")
    if override:
        return override
    return DAEMON_PROCESS_NAME


def _safe_run(argv: list[str], timeout_s: float = 5.0) -> str:
    """Run a command; return its stdout or an empty string + best-
    effort error context. Never raises."""
    try:
        result = subprocess.run(
            argv,
            check=False,
            capture_output=True,
            text=True,
            timeout=timeout_s,
        )
        return result.stdout
    except FileNotFoundError:
        return f"(command not found: {argv[0]})"
    except subprocess.TimeoutExpired:
        return f"(command timed out after {timeout_s}s: {' '.join(argv)})"
    except OSError as e:
        return f"(command failed: {e})"


def daemon_state() -> str:
    """Live state of any daemon-process-name matches on this host.
    Identifies the pid, status, and wchan — diagnostic gold when
    a test deadlocks on an unresponsive daemon.

    Daemon name resolved via `_daemon_process_name()`:
    `MGF_TEST_SUPERVISOR_DAEMON_PROCESS_NAME` env var wins,
    falling back to the module constant.

    Returns the empty string when no daemon is configured (mgf-common
    case) so the supervisor's bundle just shows `(empty)` for the
    PROCESS STATE section.

    Example::

        daemon_state()  # -> a human-readable daemon status line
    """
    name = _daemon_process_name()
    if not name:
        return ""
    pgrep = shutil.which("pgrep")
    if not pgrep:
        return "(pgrep not available; cannot enumerate daemon processes)"
    out = _safe_run([pgrep, "-af", name])
    if not out.strip():
        return f"no `{name}` processes running"

    lines = [f"live `{name}` processes:"]
    for raw in out.strip().splitlines():
        # `pgrep -af` prints "PID command-line"
        parts = raw.split(None, 1)
        if not parts:
            continue
        try:
            pid = int(parts[0])
        except ValueError:
            continue
        cmdline = parts[1] if len(parts) > 1 else "(no cmdline)"
        lines.append(f"  pid {pid}: {cmdline}")
        # /proc/<pid>/wchan tells us what the kernel is waiting on.
        try:
            wchan = Path(f"/proc/{pid}/wchan").read_text().strip()
            lines.append(f"    wchan: {wchan or '(running)'}")
        except OSError as e:
            lines.append(f"    wchan: (capture failed: {e})")
        # /proc/<pid>/status carries thread count + state.
        try:
            status = Path(f"/proc/{pid}/status").read_text()
            for s_line in status.splitlines():
                if s_line.startswith(("State:", "Threads:", "VmRSS:")):
                    lines.append(f"    {s_line.strip()}")
        except OSError as e:
            lines.append(f"    status: (capture failed: {e})")
    return "\n".join(lines)


def dmesg_tail(n: int = 30, filter_str: str | None = None) -> str:
    """Last N lines of dmesg. May be empty on locked-down hosts
    (dmesg-restrict).

    Example::

        dmesg_tail(n=30)  # -> last 30 kernel-log lines (or a note if unavailable)
    """
    dmesg = shutil.which("dmesg")
    if not dmesg:
        return "(dmesg not available)"
    out = _safe_run([dmesg, "-T"])
    if out.startswith("("):
        return out
    if not out.strip():
        return "(dmesg readable but empty — kernel.dmesg_restrict?)"
    lines = out.splitlines()
    if filter_str:
        lines = [line for line in lines if filter_str in line]
    return "\n".join(lines[-n:])


def env_fingerprint() -> str:
    """Project-wide environment fingerprint. Python version,
    mgf.common install path (catches the worktree-shadowed install
    trap), current git HEAD + branch.

    Imports are lazy + caught — if mgf.common itself is broken
    (which is exactly when this matters most), we still emit
    something. Adapt the import line per project; the lazy/caught
    shape is the portable bit.

    Example::

        env_fingerprint()  # -> 'python=3.12.3 platform=Linux-x86_64 ...'
    """
    lines = [f"python: {sys.version.split()[0]} ({sys.executable})"]

    try:
        import mgf.common

        lines.append(f"mgf.common: {mgf.common.__file__}")
        lines.append(f"mgf.common.__version__: {mgf.common.__version__}")
    except (ImportError, AttributeError) as e:
        lines.append(f"mgf.common: (import failed: {e})")

    git = shutil.which("git")
    if git:
        head = _safe_run([git, "rev-parse", "HEAD"]).strip()
        branch = _safe_run([git, "rev-parse", "--abbrev-ref", "HEAD"]).strip()
        lines.append(f"git: {branch} @ {head[:12] if head else '?'}")

    lines.append(f"cwd: {Path.cwd()}")
    return "\n".join(lines)


def process_tree(pid: int) -> str:
    """`ps -auxf` rooted at `pid`. Useful when a test spawned
    subprocesses that may have orphaned.

    Example::

        import os
        process_tree(os.getpid())
    """
    ps = shutil.which("ps")
    if not ps:
        return "(ps not available)"
    # `ps -p <pid> -o pid,ppid,state,etime,command` is more
    # portable than `ps auxf | grep`.
    out = _safe_run([ps, "-p", str(pid), "-o", "pid,ppid,state,etime,command"])
    return out or f"(no process with pid {pid})"


def recent_history(test_id: str, durations_path: Path, n: int = 5) -> str:
    """Last N outcomes for `test_id` from the durations store.
    Returns a one-line summary like:
        passed (0.12s), passed (0.10s), failed (0.15s), ...


    Example::

        recent_history("t.py::test_x", Path(".gates/durations.json"), n=5)
    """
    if not durations_path.exists():
        return "(no history yet)"
    try:
        import json

        data = json.loads(durations_path.read_text(encoding="utf-8"))
    except (OSError, ValueError) as e:
        return f"(history unreadable: {e})"
    entry = data.get(test_id)
    if not entry:
        return "(no recorded runs for this test)"
    history = entry.get("history", [])
    if not history:
        return "(no history records)"
    bits = []
    for h in history[-n:]:
        outcome = h.get("outcome", "?")
        dur = h.get("duration_ms", 0) / 1000.0
        bits.append(f"{outcome} ({dur:.2f}s)")
    return ", ".join(bits)


__all__ = [
    "DAEMON_PROCESS_NAME",
    "daemon_state",
    "dmesg_tail",
    "env_fingerprint",
    "process_tree",
    "recent_history",
]
