"""Shared broker-process helpers for the test suite.

The top-level `tests/conftest.py` builds the `broker` + `sock_path`
fixtures on top of these. Suite-specific conftests (none today)
would import from here too — never re-spawn the broker from
scratch in suite code.

**Federation reuse note.** Any project that ships a built-binary
broker (mgf-hrb today, mgf-hrb-usb-priv shortly, future workers)
can drop this file in as-is. The only project-specific knob is
`server_path()` — adjust the binary name and you're done.

Design ground:

* **Fail fast on stale binaries.** `broker_is_stale` compares the
  binary mtime to the newest `*.zig` source mtime. A False here
  means "binary is at least as fresh as the latest source"; True
  means "rebuild or you'll silently run last week's broker." This
  guards the exact trap that cost the 2026-05-25 session ~30 min
  of debugging.
* **Per-worker socket paths.** `default_sock_path()` honours
  `PYTEST_XDIST_WORKER` so future `pytest -n auto` runs don't
  collide on `/tmp/mgf-hrb.sock`. Today every test runs in the
  `main` worker — the path is `…-main.sock`.
* **Bounded waits with explicit deadlines.** Every blocking step
  (socket appearance, process exit) has a numeric timeout. A
  helper that "waits forever" is the wrong shape; the call site
  always gets a typed `TimeoutError` it can surface.
"""

from __future__ import annotations

import os
import signal
import socket
import subprocess
import time
from pathlib import Path

_REPO_ROOT = Path(__file__).resolve().parents[1]
_SERVER_REL = Path("hrb-code") / "zig-out" / "bin" / "mgf-hrb-server"
_ZIG_SRC_DIR = _REPO_ROOT / "hrb-code" / "src"

# Default wait for the broker to ACCEPT connections (bind + listen). Startup
# is sub-50ms typically; 5s is deep headroom for a CPU-contended CI agent
# (the bind→listen→accept path is what `wait_for_socket` now probes — see there
# for why an existence check raced). Configurable per-call.
DEFAULT_SOCKET_DEADLINE_S = 5.0

# SIGTERM grace before SIGKILL on teardown. 5s is enough for the
# broker's accept loop to wind down without forcing a kill.
DEFAULT_SHUTDOWN_DEADLINE_S = 5.0


def server_path() -> Path:
    """Absolute path to the broker binary. Always returns a path
    (existence is the caller's concern via `server_exists()` or
    `broker_is_stale()`)."""
    return _REPO_ROOT / _SERVER_REL


def server_exists() -> bool:
    """`True` if the broker binary is present on disk. Skip-decision
    fuel for fixtures."""
    return server_path().exists()


def build_command() -> str:
    """Human-readable rebuild incantation. Surfaced in skip + freshness
    failure messages so the contributor sees the fix inline. Mirrors
    the ziglang-on-PyPI invocation documented in `docs/testing/EXECUTION.md`."""
    return (
        ".venv/lib/python3.13/site-packages/ziglang/zig "
        "build --build-file hrb-code/build.zig "
        "--cache-dir hrb-code/.zig-cache"
    )


def _is_build_input(p: Path) -> bool:
    """Return True if `p` is a build input (linked into the binary).

    PAPER-71 (mgf-common FEEDBACK, 2026-05-27): the freshness check
    used to glob every `*.zig` under the source tree, which swept
    `*_test.zig` files into the "build inputs" set. Editing a test
    file would flip the binary to "stale" even though `zig build`
    doesn't link tests into the binary at all.

    Filtered out:
      * `*_test.zig` — Zig's convention for unit-test files.
      * `tests/` anywhere in the path — defensive against test
        directories at any depth (matches the federation pattern).
      * The root source files of SIBLING binaries — these are
        the `root_source_file` arguments to `addExecutable` in
        `hrb-code/build.zig` for executables OTHER than the server
        (the helper, the scan tool, the watch tool). They're
        compile-roots of separate binaries, not transitively
        imported into `server.zig`'s tree (2026-05-27, R4): editing
        the helper's main file would otherwise mark the server as
        stale even though `zig build` correctly doesn't relink it.
    """
    if p.name.endswith("_test.zig"):
        return False
    if "tests" in p.parts:
        return False
    if p.name in _SIBLING_BINARY_ROOTS:
        return False
    if p.name in _TEST_ONLY_MODULES:
        return False
    return True


# Root source files of sibling executables defined in
# `hrb-code/build.zig` — these are entrypoints for separate binaries,
# not transitive imports of `src/server.zig`. Keep in sync with
# `addExecutable(... root_source_file ...)` calls in build.zig that
# AREN'T the server. (The server's own root, `src/server.zig`, is
# intentionally NOT here — its mtime obviously affects the server
# binary's freshness.)
_SIBLING_BINARY_ROOTS: frozenset[str] = frozenset(
    {
        "usb_priv_main.zig",  # → mgf-hrb-usb-priv
        "usb_scan.zig",  # → mgf-hrb-usb-scan
        "usb_watch.zig",  # → mgf-hrb-usb-watch
    }
)

# Modules that have their OWN `zig build test` target but are NOT (yet)
# transitively imported by `src/server.zig` — so editing them doesn't
# relink the server binary, and they must not flip it to "stale".
# Distinct from sibling-binary roots: these aren't separate executables,
# just standalone unit-tested modules whose production wiring is pending.
#   * `shm.zig` (Tier-0 #5) — the per-tenant region pool; joins the
#     server's import graph when the shm data plane is wired (the
#     follow-on). Until then it's exercised only by its own test target.
# When such a module IS imported by server.zig, remove it from here so
# its mtime correctly affects the binary's freshness again.
_TEST_ONLY_MODULES: frozenset[str] = frozenset(
    {
        "shm.zig",
    }
)


def broker_is_stale() -> tuple[bool, str]:
    """Return `(is_stale, reason)`.

    Stale when the broker binary mtime is older than the newest
    build-input `*.zig` mtime under `hrb-code/src/`. Catches the
    2026-05-25 trap: binary built before a source change, tests
    against the old binary deadlock silently.

    Test-only files (`*_test.zig`, anything under `tests/`) are
    excluded from the build-inputs set — see :func:`_is_build_input`
    and PAPER-71 in `mgf-common/FEEDBACK.md`.

    Returns `(False, "")` when the binary is fresh, when the binary
    is missing (caller's job to skip), or when there are no Zig
    sources to compare against (defensive — shouldn't happen).
    """
    binary = server_path()
    if not binary.exists():
        return False, ""  # absence is not staleness; caller skips
    binary_mtime = binary.stat().st_mtime

    newest_src_mtime = 0.0
    newest_src_path = ""
    for src in _ZIG_SRC_DIR.rglob("*.zig"):
        if not _is_build_input(src):
            continue
        m = src.stat().st_mtime
        if m > newest_src_mtime:
            newest_src_mtime = m
            newest_src_path = str(src.relative_to(_REPO_ROOT))

    if newest_src_mtime == 0.0:
        return False, ""

    if newest_src_mtime > binary_mtime:
        delta = newest_src_mtime - binary_mtime
        return True, (
            f"broker binary {_SERVER_REL} is older than {newest_src_path} "
            f"by {delta:.0f}s. Rebuild: {build_command()}"
        )
    return False, ""


def default_sock_path() -> str:
    """Broker socket path, discriminated per pytest-xdist worker.

    Under `pytest -n auto`, xdist sets `PYTEST_XDIST_WORKER` (e.g. `gw0`)
    per worker process; each gets its own `/tmp/mgf-hrb-{worker}.sock` so
    parallel broker spawns don't collide on a shared path. Single-worker
    (serial) runs have no such env var → the historical `/tmp/mgf-hrb.sock`.
    `spawn_broker` hands this to the broker via the `MGF_HRB_SOCK` env
    override (see `hrb-code/src/server.zig` `envStr`).
    """
    worker = os.environ.get("PYTEST_XDIST_WORKER")
    if worker:
        return f"/tmp/mgf-hrb-{worker}.sock"
    return "/tmp/mgf-hrb.sock"


def _unlink_socket(sock: str) -> None:
    p = Path(sock)
    if p.exists():
        try:
            p.unlink()
        except FileNotFoundError:
            pass  # raced with another teardown — fine


def wait_for_socket(sock: str, deadline_s: float) -> None:
    """Block until `sock` ACCEPTS a connection, up to `deadline_s` seconds.

    Probes by actually connecting, not by checking the file exists: a Unix
    socket file appears at ``bind()`` but refuses connections until the broker
    calls ``listen()``. A mere existence check therefore races — under CPU
    contention the bind→listen window widens and the fixture's real
    ``Client.connect`` hits ``ConnectionRefusedError`` (the intrinsic broker-
    test flake). Connecting-and-closing is the only readiness signal that
    matches what the caller does next.

    Polls at 20ms intervals. Raises `TimeoutError` (not `pytest.fail`) so the
    call site decides the failure shape — fixtures want pytest.fail, ad-hoc
    callers may want a typed Python exception.
    """
    poll_s = 0.02
    end = time.monotonic() + deadline_s
    last = "no socket file yet"
    while time.monotonic() < end:
        probe = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
        try:
            probe.connect(sock)
            return  # broker is listening + accepting
        except (FileNotFoundError, ConnectionRefusedError) as e:
            last = type(e).__name__  # bind not done / listen not done yet
        finally:
            probe.close()
        time.sleep(poll_s)
    raise TimeoutError(
        f"broker never accepted on {sock} within {deadline_s:.1f}s (last: {last})"
    )


def spawn_broker(
    sock: str, *, timeout_s: float = DEFAULT_SOCKET_DEADLINE_S
) -> subprocess.Popen[bytes]:
    """Spawn the broker subprocess targeting `sock`, wait for it to
    bind, return the live `Popen`. Caller owns teardown via
    `teardown_broker`.

    Pre-unlinks any leftover socket from a crashed prior run so the
    new broker can bind cleanly. `sock` is passed to the broker via the
    `MGF_HRB_SOCK` env override so per-worker xdist sockets work.
    """
    _unlink_socket(sock)
    proc = subprocess.Popen(  # noqa: S603 — argv-form, trusted path
        [str(server_path())],
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        env={**os.environ, "MGF_HRB_SOCK": sock},
    )
    try:
        wait_for_socket(sock, timeout_s)
    except TimeoutError:
        proc.kill()
        proc.wait(timeout=2)
        raise
    return proc


def teardown_broker(
    proc: subprocess.Popen[bytes],
    sock: str,
    *,
    deadline_s: float = DEFAULT_SHUTDOWN_DEADLINE_S,
) -> None:
    """SIGTERM → grace → SIGKILL → reap stdout → unlink socket.
    Idempotent: safe to call against a process that's already dead."""
    if proc.poll() is None:
        proc.send_signal(signal.SIGTERM)
        try:
            proc.wait(timeout=deadline_s)
        except subprocess.TimeoutExpired:
            proc.kill()
            try:
                proc.wait(timeout=2)
            except subprocess.TimeoutExpired:
                pass  # truly stuck; OS will reap
    if proc.stdout is not None:
        try:
            proc.stdout.close()
        except OSError:
            pass
    _unlink_socket(sock)


def open_client_socket(sock: str, *, timeout_s: float = 5.0) -> socket.socket:
    """Open a raw Unix socket connected to `sock` with an explicit
    receive timeout. Used by smoke tests that talk wire-level to the
    broker without the `Client` wrapper."""
    s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
    s.settimeout(timeout_s)
    s.connect(sock)
    return s
