"""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 BUILD INPUT's mtime — the `*.zig`
  sources, plus `hrb-code/build.zig` and `pyproject.toml`, both of
  which can change the binary without any `src/*.zig` being touched
  (F-B2-04). 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
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Iterator

_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 build_version() -> str:
    """The version the broker binary reports on the wire.

    ONE authority, read the same way the build reads it. `build.zig`'s
    `projectVersion()` parses `[project] version` out of `pyproject.toml`
    at configure time and passes it to the binary via `build_options`; the
    hello handshake and `state_snapshot`'s `broker_version` both render
    THAT string. So a test that wants to pin those surfaces must derive
    the expected value from the same file rather than restate it.

    This is not fussiness. Both of those wire fields were hardcoded
    `"0.1.0"` from 0.1.0 through 0.1.9 — nine releases of a broker telling
    every client the wrong number — and the assertions pinning them were
    themselves literals, so they agreed with the bug and reported green.
    A pin written as a literal cannot tell "the code is right" from "the
    code and I are wrong together." M-B B2 fixed both fields; this
    function is what stops the pins rotting again when 0.1.11 ships.
    """
    text = (_REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8")
    table = text.index("[project]")
    marker = '\nversion = "'
    start = text.index(marker, table) + len(marker)
    return text[start : text.index('"', start)]


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 _build_inputs() -> Iterator[Path]:
    """Every file whose mtime can change the built broker binary.

    The `*.zig` sources under `hrb-code/src/`, filtered by
    :func:`_is_build_input` — plus two files that are not Zig at all and
    were invisible to the guard until F-B2-04 (M-B B2 court):

      * **`hrb-code/build.zig`** — the build graph itself. Adding a module,
        an import edge, or a compile-time option changes the binary while
        touching no source file under `src/`.
      * **`pyproject.toml`** — since M-B B2 the broker's reported version
        is compiled in from this file (`build.zig`'s `projectVersion()`
        → `build_options.broker_version`), so a version bump genuinely
        changes the binary.

    The second one is the reason this exists. Bump the version, forget to
    rebuild, and the version pins fail with a bare `'0.1.11' != '0.1.10'`
    — an assertion that looks like the BROKER is wrong when the truth is
    that the binary is a build behind. The guard's whole job is to say
    "rebuild" instead of letting a stale binary answer questions, and it
    could not do that for the one input most likely to be edited without a
    rebuild.
    """
    for src in _ZIG_SRC_DIR.rglob("*.zig"):
        if _is_build_input(src):
            yield src
    for extra in (_REPO_ROOT / "hrb-code" / "build.zig", _REPO_ROOT / "pyproject.toml"):
        if extra.exists():
            yield extra


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 _build_inputs():
        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
