"""Resolve + drive the Zig build system for a project (the ``zig`` runner).

testctl runs as an isolated ``uv`` tool with no Zig toolchain of its own, so the
zig runner resolves an invocation at call time: a real ``zig`` binary on PATH,
else ``python -m ziglang`` under any interpreter that actually carries the
``ziglang`` wheel (the way the fleet's CI drives Zig — ``python -m ziglang build
test``). Each candidate is *probed* (``… version`` must exit 0), never assumed.

**Enumeration is step-level.** Zig has no per-test listing — 0.16 rejects
``zig build test --list`` outright — so the catalog unit is a build *step*
(``zig build --list-steps``), and the test entries are the conventional ``test``
step (and any ``test``-prefixed step). Richer tiers (``safety``/``conformance``/
``fuzz`` &c.) are repo-specific and variably named; classifying them is left to a
future repo-declared convention rather than a fragile keyword guess.
"""

from __future__ import annotations

import re
import shutil
import subprocess
import sys
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from pathlib import Path

ZIG_RUNNER_ID = "zig"

#: PATH interpreter names to probe for the ``ziglang`` wheel, then the common
#: system interpreter and finally testctl's own — each gated by an actual probe.
_PY_CANDIDATES = ("python3", "python")
_FALLBACK_PY = ("/usr/bin/python3",)

# A ``--list-steps`` line: indent, step name, optional " (default)", 2+ spaces,
# then the description. Description is optional (a step may have none).
_STEP_RE = re.compile(r"^\s+(\S+?)(?:\s+\(default\))?(?:\s{2,}(.*))?\s*$")


class ZigUnavailable(RuntimeError):  # noqa: N818 — "...Unavailable" reads truer than "...Error"
    """No Zig toolchain found — no ``zig`` binary and no ``python -m ziglang``."""


class ZigError(RuntimeError):
    """``zig build`` itself failed (e.g. a broken ``build.zig``); carries output."""


def _probe(cmd: list[str]) -> bool:
    try:
        return (
            subprocess.run(  # noqa: S603 — fixed argv (a version probe), not user input
                cmd, capture_output=True, timeout=30
            ).returncode
            == 0
        )
    except (OSError, subprocess.SubprocessError):
        return False


def zig_cmd() -> list[str] | None:
    """A runnable Zig invocation prefix, or ``None`` if no toolchain is present.

    ``["zig"]`` for a working ``zig`` binary, else ``[<python>, "-m", "ziglang"]``
    for the first interpreter that actually carries the wheel.
    """
    zig = shutil.which("zig")
    if zig and _probe([zig, "version"]):
        return [zig]
    seen: list[str] = []
    for name in _PY_CANDIDATES:
        found = shutil.which(name)
        if found and found not in seen:
            seen.append(found)
    for py in [*seen, *_FALLBACK_PY, sys.executable]:
        if py and _probe([py, "-m", "ziglang", "version"]):
            return [py, "-m", "ziglang"]
    return None


def parse_list_steps(text: str) -> list[tuple[str, str]]:
    """``(name, description)`` for each step line in ``zig build --list-steps`` output."""
    steps: list[tuple[str, str]] = []
    for line in text.splitlines():
        match = _STEP_RE.match(line)
        if match:
            steps.append((match.group(1), (match.group(2) or "").strip()))
    return steps


def list_steps(project_root: str | Path, zig: list[str] | None = None) -> list[tuple[str, str]]:
    """Every build step (name, description) in ``project_root`` via ``--list-steps``.

    Raises :class:`ZigUnavailable` if no toolchain, :class:`ZigError` if the build
    system errors.
    """
    zig = zig or zig_cmd()
    if zig is None:
        raise ZigUnavailable(
            "no Zig toolchain found — install `ziglang` (pip/uv) or put `zig` on PATH"
        )
    proc = subprocess.run(  # noqa: S603 — argv from zig_cmd() + a fixed flag, not user input
        [*zig, "build", "--list-steps"],
        cwd=str(project_root),
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        text=True,
        check=False,
    )
    if proc.returncode != 0:
        tail = "\n".join(proc.stdout.strip().splitlines()[-8:])
        raise ZigError(
            f"`zig build --list-steps` failed in {project_root} (exit {proc.returncode})\n{tail}"
        )
    return parse_list_steps(proc.stdout)


#: Build-step names that are test tiers across the fleet, beyond the conventional
#: ``test`` step (+ any ``test``-prefixed). Kept precise — every name here is
#: unambiguously test-oriented — so a non-test step (``run``/``install``/``docs``/…)
#: is never enumerated as a runnable test. Seen in the fleet: ``safety`` (soothfast),
#: ``smoke``/``conformance`` (soothwatch), ``fuzz`` (soothtroth). A repo whose tier
#: is named otherwise is a candidate for a future repo-declared override; this is the
#: sensible default.
_TEST_STEP_NAMES = frozenset(
    {"test", "tests", "safety", "smoke", "conformance", "fuzz", "stress", "e2e", "integration"}
)


def is_test_step(name: str) -> bool:
    """Whether a build-step name denotes a test tier (see :data:`_TEST_STEP_NAMES`)."""
    return name.startswith("test") or name in _TEST_STEP_NAMES


def test_steps(project_root: str | Path, zig: list[str] | None = None) -> list[tuple[str, str]]:
    """The repo's test steps: the conventional ``test`` step, any ``test``-prefixed
    step, and the known test tiers (``safety``/``smoke``/``conformance``/``fuzz``/…).
    Non-test steps (``install``/``run``/``docs``/…) are excluded."""
    return [(name, desc) for name, desc in list_steps(project_root, zig) if is_test_step(name)]


def build_step_cmd(step: str, zig: list[str] | None = None) -> list[str] | None:
    """The command that runs one build step (``zig build <step>``), or ``None`` if no toolchain."""
    zig = zig or zig_cmd()
    return None if zig is None else [*zig, "build", step]
