"""The federation **gate floor** — what every member's CI must actually RUN.

Three holes the 2026-07-11 fleet wave proved, closed here as pure, testable
functions consumed by ``drift-check`` (``_drift.check``) and ``app-doctor``
(``cli/_doctor.py``, ``cli/_doctor_standards.py``):

1. **Required COMMANDS, not step names** (PAPER-121). The policy's
   ``woodpecker_required_steps`` list checks step *names*, which stay identical
   while the commands inside them rot; and repos in ``[exempt].woodpecker`` skip
   the generation-currency comparison entirely. So a gate added to the canonical
   template reached every repo EXCEPT the exempt ones, silently — proven when
   ``ruff format --check`` (PAPER-115) shipped and all three Python-linting
   exempt repos (mgf-hrb, mgf-test-supervisor, **mgf-standard** — the repo that
   authored the gate) still lacked it *and* carried real format drift.
   :func:`missing_commands` asserts a command list against EVERY member's
   committed pipeline, exempt or not. Exempt means "we don't regenerate your
   file", NOT "you may silently drop a federation gate".

2. **The supervisor-coverage ↔ pyproject coupling** (PAPER-122). The canonical
   coverage step runs ``coverage run --source=… -m mgf.test_supervisor``, which
   runs pytest in CHILD processes. Without ``[tool.coverage.run]
   patch = ["subprocess"]`` (coverage >= 7.10) the build reds with **every test
   passing** ("No data was collected" → ``coverage combine`` → "No data to
   combine" → exit 1). Hit three repos in one wave.
   :func:`coverage_patch_gap` names the coupling before the confusing failure
   does.

3. **A member with NO pipeline at all** (PAPER-122, mgf-standard). A registry
   member without a ``.woodpecker.yml`` makes every Woodpecker run error out
   ("could not load config from forge") — so NO gate has ever run on it, and
   nothing we own noticed. :func:`pipeline_missing_exempt` + the
   ``ci-missing`` drift item close that: absence is drift for every member,
   whatever its language, unless it is explicitly excused.

Policy keys (both OPTIONAL; they belong in ``mgf-standard``'s policy — i.e.
``[tool.mgf-fed.policy]`` in its ``pyproject.toml``, or the deprecated
``templates/policy.toml`` — and mgf-fed defaults sensibly when they are absent,
so the gate is live from day one and the key can land later):

.. code-block:: toml

    # Commands that MUST appear in every Python member's .woodpecker.yml,
    # exempt or not (substring match against the pipeline's command lines).
    required_commands = ["ruff check", "ruff format --check", "check_commit_trailers.py"]

    [exempt]
    # Members that legitimately ship NO .woodpecker.yml. Empty by default:
    # a member with no pipeline runs no gates, so absence must be a decision,
    # never an accident.
    no_pipeline = []
"""

from __future__ import annotations

import re
from typing import TYPE_CHECKING, Any

if TYPE_CHECKING:
    from collections.abc import Mapping

#: The gate floor used when the policy carries no ``required_commands`` key.
#: These are the *Python* gates — the lint gate (both halves: check AND format,
#: PAPER-115) and the AG-03 commit-trailer gate. Kept as substrings so a bespoke
#: pipeline stays free in shape (its own image, flags, paths) while staying
#: honest on gates.
DEFAULT_REQUIRED_COMMANDS: tuple[str, ...] = (
    "ruff check",
    "ruff format --check",
    "check_commit_trailers.py",
)

#: The canonical supervisor-under-coverage invocation (base.yml's 3.12 gate).
_SUPERVISOR_MODULE = "-m mgf.test_supervisor"

_COMMANDS_KEY_RE = re.compile(r"^(?:commands|entrypoint)\s*:\s*(.*)$")


def required_commands(policy: Mapping[str, Any]) -> tuple[str, ...]:
    """The commands every member's pipeline must run.

    Sourced from the policy's ``required_commands`` list when present; falls
    back to :data:`DEFAULT_REQUIRED_COMMANDS` when absent or empty, so the gate
    is enforced whether or not the key has landed in mgf-standard's policy yet.
    """
    raw = policy.get("required_commands")
    if isinstance(raw, list):
        cmds = tuple(str(c).strip() for c in raw if isinstance(c, str) and c.strip())
        if cmds:
            return cmds
    return DEFAULT_REQUIRED_COMMANDS


def pipeline_missing_exempt(policy: Mapping[str, Any]) -> tuple[str, ...]:
    """Members explicitly excused from shipping a ``.woodpecker.yml`` at all
    (``[exempt].no_pipeline``). Empty by default — see the module docstring.

    Deliberately NOT ``[exempt].woodpecker``: that list excuses a repo from the
    *generation-currency* comparison (we don't regenerate your file), which must
    never be read as permission to have no pipeline — mgf-hrb dropping its
    hand-maintained pipeline is exactly as bad as mgf-brand never having one.
    """
    exempt = policy.get("exempt")
    if not isinstance(exempt, dict):
        return ()
    raw = exempt.get("no_pipeline")
    if not isinstance(raw, list):
        return ()
    return tuple(str(r) for r in raw if isinstance(r, str))


def command_lines(text: str) -> list[str]:
    """Every shell command a ``.woodpecker.yml`` runs, as flat strings.

    Zero-dep (no YAML parser in the drift path, by design — see ``_drift``):
    walks the file tracking ``commands:`` / ``entrypoint:`` keys and collects the
    sequence items (or block-scalar lines) beneath them. Step-scoped by
    construction: a `- name: lint` sequence item under `steps:` is NOT a command,
    and a command is never confused with a key. Comment lines are dropped — a
    commented-out gate is not a gate.
    """
    out: list[str] = []
    cmd_indent: int | None = None
    for raw in text.splitlines():
        stripped = raw.strip()
        if not stripped or stripped.startswith("#"):
            continue
        indent = len(raw) - len(raw.lstrip())
        if cmd_indent is not None:
            if indent >= cmd_indent and stripped.startswith("-"):
                out.append(stripped[1:].strip())  # sequence item: `- <command>`
                continue
            if indent > cmd_indent:
                out.append(stripped)  # block scalar (`commands: |`) continuation
                continue
            cmd_indent = None  # dedent (or a sibling key) closes the block
        m = _COMMANDS_KEY_RE.match(stripped)
        if m is None:
            continue
        inline = m.group(1).strip()
        if inline and not inline.startswith(("|", ">", "#", "[")):
            out.append(inline)  # `entrypoint: /bin/sh` — inline scalar, no block
            continue
        if inline.startswith("["):  # `commands: [a, b]` — inline flow sequence
            out += [p.strip().strip("'\"") for p in inline.strip("[]").split(",") if p.strip()]
            continue
        cmd_indent = indent
    return [c for c in (c.strip() for c in out) if c and not c.startswith("#")]


def missing_commands(text: str, required: tuple[str, ...] | list[str]) -> list[str]:
    """Which ``required`` commands the pipeline ``text`` does NOT run (PAPER-121).

    Substring match, one command line at a time — so ``"ruff check"`` is
    satisfied by ``ruff check src tests`` or ``uv run ruff check .`` but not by a
    step merely *named* ``lint``, and never by a match spanning two lines.

    Fail-open on shape, not on gates: if the file has no recognisable
    ``commands:`` block at all (an unusual hand-rolled pipeline), fall back to
    matching against the raw non-comment lines rather than declaring every gate
    missing.
    """
    lines = command_lines(text)
    if not lines:
        lines = [s for ln in text.splitlines() if (s := ln.strip()) and not s.startswith("#")]
    return [cmd for cmd in required if not any(cmd in line for line in lines)]


def ui_gate_gap(woodpecker_text: str, pyproject: Mapping[str, Any]) -> bool:
    """A console depends on ``mgf-uitest-web`` but its pipeline never runs the UI gate.

    The WUI-07 coupling, and the same shape as the coverage one: both halves are read
    from the repo's own committed files, and the consequence is certain — the browser
    specs exist, the harness is installed, and **nothing ever runs them**. That is the
    failure this whole tier exists to prevent: a console's `addopts` deselect the
    tier-3 markers, so UI specs left to a normal ``pytest -q`` are silently deselected.
    A pipeline that was never re-rendered after the dependency landed looks green while
    gating nothing.

    Detected on the ``pytest tests/e2e -m e2e`` invocation rather than a step *named*
    ui-gate — a step name is not a gate (PAPER-121); what matters is that the tier is
    explicitly selected somewhere in the pipeline.

    An empty/unparseable ``pyproject`` cannot be judged — returns ``False`` (never
    invent a failure from a file we could not read).
    """
    if not pyproject:
        return False
    project = pyproject.get("project")
    if not isinstance(project, dict):
        return False
    reqs: list[str] = []
    core = project.get("dependencies")
    if isinstance(core, list):
        reqs += [r for r in core if isinstance(r, str)]
    opt = project.get("optional-dependencies")
    if isinstance(opt, dict):
        for group in opt.values():
            if isinstance(group, list):
                reqs += [r for r in group if isinstance(r, str)]
    consumes = any(re.match(r"^\s*mgf-uitest-web(?![\w.-])", r, re.IGNORECASE) for r in reqs)
    if not consumes:
        return False
    return not any("-m e2e" in line and "pytest" in line for line in command_lines(woodpecker_text))


def runs_supervisor_under_coverage(text: str) -> bool:
    """True when the pipeline runs ``mgf.test_supervisor`` under ``coverage run``
    — the invocation that spawns pytest in CHILD processes (PAPER-122)."""
    return any(
        "coverage run" in line and _SUPERVISOR_MODULE in line for line in command_lines(text)
    )


def has_subprocess_patch(pyproject: Mapping[str, Any]) -> bool:
    """True when pyproject carries ``[tool.coverage.run] patch = ["subprocess"]``."""
    tool = pyproject.get("tool")
    coverage = tool.get("coverage") if isinstance(tool, dict) else None
    run = coverage.get("run") if isinstance(coverage, dict) else None
    patch = run.get("patch") if isinstance(run, dict) else None
    return isinstance(patch, list) and "subprocess" in patch


def coverage_patch_gap(woodpecker_text: str, pyproject: Mapping[str, Any]) -> bool:
    """The PAPER-122 coupling: the pipeline runs the supervisor under coverage,
    but pyproject does not patch subprocess coverage.

    Exactly this pair reds the build with **every test passing** — so the check
    is a FAIL, not a warning, wherever it is surfaced: the condition is precise
    (both halves of the coupling are read from the repo's own committed files),
    the consequence is a certain, already-observed build failure, and the
    fix is one three-line table.

    An empty/unparseable ``pyproject`` mapping cannot be judged — the caller
    passes ``{}`` and gets ``False`` (never invent a failure from a file we
    could not read).
    """
    if not pyproject:
        return False
    return runs_supervisor_under_coverage(woodpecker_text) and not has_subprocess_patch(pyproject)


#: The remediation text — one string, shared by drift-check and both doctors so
#: the fleet reads the same fix in every surface it meets the failure.
COVERAGE_PATCH_FIX = (
    "add to pyproject.toml:\n"
    "    [tool.coverage.run]\n"
    '    patch = ["subprocess"]\n'
    "  (coverage >= 7.10 — TESTING.md §19.8). The supervisor runs pytest in CHILD "
    "processes; without the patch key coverage sees nothing and the build reds with "
    'EVERY TEST PASSING ("No data was collected" → "No data to combine" → exit 1).'
)


#: The WUI-07 remediation — shared by drift-check and both doctors, same as above.
UI_GATE_FIX = (
    "this repo depends on mgf-uitest-web but .woodpecker.yml never runs its browser "
    "specs — run `mgf-fed scaffold <repo>` to render the `ui-gate` step (needs "
    "mgf-standard >= 0.1.64 + mgf-fed >= 0.14.0). The specs exist, the harness "
    "installs, and NOTHING RUNS THEM: a console's pytest addopts deselect the tier-3 "
    "markers, so UI specs left to a plain `pytest -q` are silently deselected. The "
    "gate must select the tier explicitly (`pytest tests/e2e -m e2e`)."
)


__all__ = [
    "COVERAGE_PATCH_FIX",
    "DEFAULT_REQUIRED_COMMANDS",
    "UI_GATE_FIX",
    "command_lines",
    "coverage_patch_gap",
    "has_subprocess_patch",
    "missing_commands",
    "pipeline_missing_exempt",
    "required_commands",
    "runs_supervisor_under_coverage",
    "ui_gate_gap",
]
