"""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 clone_exempt(policy: Mapping[str, Any]) -> tuple[str, ...]:
    """Members excused the hardened-clone gate floor (``[exempt].clone``). Empty by
    default — the same defensive read as :func:`pipeline_missing_exempt`.

    A repo whose clone legitimately cannot use the SSH deploy key or a full clone
    (a documented, deliberate exception) records itself here; every other member is
    held to ``partial: false`` + ``use-ssh: true`` (CIR-03 / PAPER-128). Being on
    ``[exempt].woodpecker`` (generation-currency) never implies this: "we don't
    regenerate your file" is not "you may silently ride the flaky default clone".
    """
    exempt = policy.get("exempt")
    if not isinstance(exempt, dict):
        return ()
    raw = exempt.get("clone")
    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)]


#: The two properties EVERY committed ``.woodpecker.yml`` must carry on its clone:
#: the WF-09 full clone and the PAPER-109 SSH clone. Substrings, matched over the
#: pipeline's NON-COMMENT lines — the canonical clone block's OWN comments mention
#: ``partial: false`` in prose, so a raw-text scan would credit a repo that had
#: since dropped the real setting.
_CLONE_HARDENING: tuple[str, ...] = ("partial: false", "use-ssh: true")


def clone_hardening_gap(text: str) -> list[str]:
    """Which of the hardened-clone properties the pipeline ``text`` is MISSING (CIR-03).

    Every member's committed ``.woodpecker.yml`` must carry the hardened clone:
    ``partial: false`` (the WF-09 full clone that dodges the Codeberg promisor 504)
    AND ``use-ssh: true`` (the PAPER-109 SSH clone that retired the flaky OAuth
    credential). A pipeline with NO clone block silently inherits Woodpecker's
    default — a partial clone over an OAuth credential — and "an absent block is the
    most invisible kind of drift" (PAPER-128).

    Substring match, like :func:`missing_commands`: presence ANYWHERE in the file
    counts (a bespoke pipeline stays free in shape), but only on NON-COMMENT lines —
    a commented-out ``partial: false`` is not a setting, and the canonical block's own
    comments name it. Whitespace is collapsed and the line lower-cased before matching,
    so ``partial:  False`` still counts. Returns the missing properties in canonical
    order; empty when both are present.
    """
    lines = [
        " ".join(line.split()).lower()
        for line in text.splitlines()
        if not line.lstrip().startswith("#")
    ]
    return [prop for prop in _CLONE_HARDENING if not any(prop in line for line in lines)]


#: The ``uv`` verbs that RE-RESOLVE the dependency graph against the index (and
#: rewrite ``uv.lock`` in place) unless pinned with ``--frozen`` (WF-14 / CIR-04 /
#: PAPER-125). ``uv lock`` (whose whole job is to resolve), ``uv tool`` (a global
#: tool install, not the project graph) and ``uv --version`` are NOT here — they do
#: not honour a project lock, so ``--frozen`` is meaningless on them.
_UV_RESOLVING_VERBS: frozenset[str] = frozenset({"run", "export", "sync"})

#: Split a single shell command line into its piped/chained sub-commands, so each
#: ``uv`` invocation is judged with ITS OWN flags — the WF-14 example itself pipes an
#: ``uv export`` into ``grep`` into ``pip-audit`` (§7.4), and a two-verb line like
#: ``uv sync --frozen && uv run pytest`` must credit ``--frozen`` only to the sync.
_SHELL_SEP_RE = re.compile(r"\|\||&&|[;|]")


def _uv_frozen_line_gap(line: str) -> list[str]:
    """Which WF-14 flags each resolving ``uv`` invocation on this command ``line`` MISSES.

    Returns one detail string per offending sub-command (empty when the line runs no
    re-resolving ``uv`` verb, or every such verb is already pinned). ``uv pip install``
    resolves too (the task's fourth verb); ``uv pip compile``/``list``/… do not.
    """
    out: list[str] = []
    for sub in _SHELL_SEP_RE.split(line):
        tokens = sub.split()
        try:
            after = tokens[tokens.index("uv") + 1 :]
        except ValueError:
            continue  # no bare `uv` command in this sub-command
        non_flags = [t for t in after if not t.startswith("-")]
        verb = non_flags[0] if non_flags else None
        if verb is None:  # e.g. `uv --version` — a global flag, no resolving verb
            continue
        if verb == "pip":
            if len(non_flags) < 2 or non_flags[1] != "install":
                continue  # `uv pip compile`/`list`/… — not the resolving install
        elif verb not in _UV_RESOLVING_VERBS:
            continue  # `uv lock` / `uv tool` / anything else — no `--frozen` contract
        need = ["--frozen"]
        if verb == "export":
            need.append("--no-emit-project")  # the project is not its own scanned dep
        missing = [f for f in need if f not in after]
        if missing:
            shown = sub.strip()[:64]
            out.append(f"`{shown}` missing {', '.join(missing)}")
    return out


def uv_frozen_gap(text: str) -> list[str]:
    """Which CI ``uv`` verbs in ``text`` re-resolve because they lack ``--frozen`` (WF-14).

    Every CI ``uv`` verb that must honour the lock — ``uv run`` / ``uv export`` /
    ``uv sync`` / ``uv pip install`` — MUST carry ``--frozen``, or it re-resolves the
    graph against the index and **rewrites ``uv.lock`` mid-pipeline** (PAPER-125 swapped
    9 pins to pypi relics that way). Audit-shaped ``uv export`` additionally carries
    ``--no-emit-project`` (the project under test is not one of its own scanned deps).

    Scanned over :func:`command_lines` — so a commented-out ``uv run`` is not a gate,
    and a step NAME never counts. Each command line is split on ``&&``/``||``/``;``/``|``
    so a two-verb line credits a flag only to its own invocation. A pip-only pipeline
    (the current fleet) runs no resolving ``uv`` verb → returns ``[]`` (silent).
    ``uv lock`` (resolve-by-design), ``uv tool`` and ``uv --version`` are never flagged.

    Returns one detail string per offending invocation, in file order; empty when clean.
    """
    out: list[str] = []
    for line in command_lines(text):
        out += _uv_frozen_line_gap(line)
    return out


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 ci-clone remediation — one string, shared everywhere the finding surfaces so
#: the fleet reads the same fix (pattern: COVERAGE_PATCH_FIX / UI_GATE_FIX).
CLONE_HARDENING_FIX = (
    "the committed .woodpecker.yml must carry the hardened clone block: "
    "`partial: false` (WF-09 full clone — dodges the Codeberg promisor 504) AND "
    "`use-ssh: true` (PAPER-109 SSH clone — retires the flaky short-lived OAuth "
    "credential). A pipeline with NO clone block silently inherits Woodpecker's default "
    "(partial clone + OAuth credential) — an absent block is the most invisible kind of "
    "drift (PAPER-128). Fix: run `mgf-fed scaffold <repo>` for a generated pipeline, or "
    "hand-add the canonical clone block (PAPER-109/128) to a bespoke one."
)


#: The ci-uv-frozen remediation — one string, shared everywhere the finding surfaces
#: (pattern: CLONE_HARDENING_FIX / COVERAGE_PATCH_FIX / UI_GATE_FIX).
UV_FROZEN_FIX = (
    "every CI `uv` verb that must honour the lock — `uv run` / `uv export` / `uv sync` "
    "/ `uv pip install` — MUST carry `--frozen`, or it re-resolves the graph against the "
    "index and rewrites `uv.lock` mid-pipeline (WF-14; PAPER-125 silently swapped 9 pins "
    "to pypi relics that way). Audit-shaped `uv export` additionally carries "
    "`--no-emit-project` (the project under test is not one of its own scanned deps). "
    "`--frozen` errors LOUDLY on a stale lock instead of rewriting it — a red, not an "
    "invisible swap. Precondition: `uv.lock` is committed (WF-11 / §7.1). Fix: add "
    "`--frozen` to the flagged verb (and `--no-emit-project` to an audit export)."
)


#: 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__ = [
    "CLONE_HARDENING_FIX",
    "COVERAGE_PATCH_FIX",
    "DEFAULT_REQUIRED_COMMANDS",
    "UI_GATE_FIX",
    "UV_FROZEN_FIX",
    "clone_exempt",
    "clone_hardening_gap",
    "command_lines",
    "coverage_patch_gap",
    "has_subprocess_patch",
    "missing_commands",
    "pipeline_missing_exempt",
    "required_commands",
    "runs_supervisor_under_coverage",
    "ui_gate_gap",
    "uv_frozen_gap",
]
