"""Cross-layer audit — the gates that compare one layer of the federation to another.

WHY THIS MODULE EXISTS
----------------------
The federation has five layers, and almost every recurring defect is an answer
obtained from one of them and believed about another:

    declared  → committed → published → installed → live
    (registry,  (main)      (devpi)     (uv tool)   (Woodpecker,
     SECRETS.toml,                                   hosts, forge)
     pyproject)

Examples, each of which cost real time:

* ``rotate`` reported six green stages having written the **store**, while the
  **live** surface still served the old value — and then revoked the old one.
* ``sync`` announced "declared == deployed" having compared **declared** against
  **live metadata**, never the live value.
* ``mgf-alembic``'s **committed** main was 28 commits past the tag whose number it
  **published**; 6 of 7 wheels shipped ``License: MIT`` against Apache-2.0 source,
  so a completed relicense had reached no consumer at all.
* A fix merged to **committed** main is not shipped until **installed** has it.

Each incident produced a bespoke verifier in the court workspace's ``tools/``.
On 2026-07-30 there were **five of them, referenced by zero pipelines**. The
knowledge was captured; the enforcement was not, so the same classes recurred and
were re-diagnosed from scratch. This module is those verifiers folded into the
gate path, where an exit code makes them real.

THE TWO PROPERTIES THAT MAKE THIS DIFFERENT FROM A PILE OF CHECKS
-----------------------------------------------------------------
1. **Every check names the boundary it crossed.** A finding says *declared ↔
   published*, not just "problem". A check that cannot state its layers is a check
   whose scope will be misread — the exact class this module exists to end.

2. **A check that could not run is a FAILURE, not a pass.** Without ``--offline``,
   a missing token or an unreachable index exits non-zero. "I could not verify" and
   "I verified" must never produce the same exit code; that equivalence is what let
   a dead webhook look like a healthy repo for three weeks.

``--offline`` is the explicit opt-in that makes the network-free subset a legitimate
run — and even then, the skipped checks are named in the output. Silence is never
success.
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import urllib.error
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

from mgf.fed._http import request_json
from mgf.fed._roster import federation_root

if TYPE_CHECKING:
    from collections.abc import Callable, Iterable, Sequence

# --------------------------------------------------------------------------- #
# Layers.
# --------------------------------------------------------------------------- #

DECLARED = "declared"  # the registries: MGF_LIBRARIES.json, SECRETS.toml, pyproject
COMMITTED = "committed"  # what origin/main actually holds
PUBLISHED = "published"  # what the index serves (devpi, and public PyPI)
INSTALLED = "installed"  # what the operator's environment actually runs
LIVE = "live"  # Woodpecker, the forge, the hosts

#: The devpi index the fleet publishes to and installs from.
DEVPI = "https://195.15.203.250.sslip.io/magogi/prod/+simple/"
_GIT_TIMEOUT_S = 30


def _wp_base() -> str:
    """The Woodpecker base URL, from the ONE place that already defines it.

    An earlier draft of this module hardcoded a plausible-looking hostname. It was
    wrong, and three checks reported SKIPPED — Woodpecker unreachable on the first
    live run. The honest-skip design caught it (a guess did not become a pass), but
    the guess should never have existed: a second definition of a shared fact is a
    second thing to drift.
    """
    import os

    from mgf.fed.cli._ping import _DEFAULT_WP_URL

    return os.environ.get("WOODPECKER_URL", _DEFAULT_WP_URL)


# --------------------------------------------------------------------------- #
# Results.
# --------------------------------------------------------------------------- #


@dataclass(frozen=True)
class Finding:
    """One cross-layer discrepancy. ``target`` is a repo/secret/name; ``detail``
    explains the gap in the terms of the two layers it sits between."""

    target: str
    detail: str


@dataclass
class CheckResult:
    """The outcome of one check, INCLUDING whether it actually ran.

    ``skipped`` is not a soft pass — :func:`run_audit` turns an unrequested skip
    into a non-zero exit. A check that reports zero findings because it never
    executed is the failure mode this whole module is built against.
    """

    name: str
    layers: tuple[str, str]
    examined: int = 0
    findings: list[Finding] = field(default_factory=list)
    skipped: str = ""  # non-empty = the reason it could not run

    @property
    def ok(self) -> bool:
        return not self.findings and not self.skipped

    def to_json(self) -> dict[str, Any]:
        return {
            "check": self.name,
            "layers": list(self.layers),
            "examined": self.examined,
            "skipped": self.skipped,
            "findings": [{"target": f.target, "detail": f.detail} for f in self.findings],
        }


# --------------------------------------------------------------------------- #
# Shared helpers.
# --------------------------------------------------------------------------- #


def _git(repo: Path, *args: str) -> str:
    try:
        return subprocess.run(  # noqa: S603 — fixed argv, repo path from the roster
            ["git", *args],  # noqa: S607 — `git` from PATH is the house convention
            capture_output=True,
            text=True,
            cwd=repo,
            timeout=_GIT_TIMEOUT_S,
            check=False,
        ).stdout.strip()
    except (OSError, subprocess.SubprocessError):
        return ""


def _repos_on_disk(root: Path) -> list[Path]:
    return sorted(p for p in root.glob("mgf-*") if (p / ".git").is_dir())


def _wp_token(explicit: str | None = None) -> str | None:
    """Same resolution chain as ``mgf-fed ping``/``ci-stats`` — one token story."""
    if explicit:
        return explicit
    if os.environ.get("WOODPECKER_TOKEN"):
        return os.environ["WOODPECKER_TOKEN"]
    stored = federation_root() / "mgf-standard" / "my_stuff" / "woodpecker_token.txt"
    if stored.is_file():
        return stored.read_text(encoding="utf-8").strip()
    return None


def _wp(path: str, token: str, base: str | None = None) -> Any:
    return request_json(f"{base or _wp_base()}/api{path}", token=token, timeout=60)


def _wp_mgf_repos(token: str, base: str | None = None) -> dict[str, dict[str, Any]]:
    """``{repo-slug: woodpecker repo object}`` for the mgf-* repos it serves."""
    listing = _wp("/repos?limit=100", token, base)
    if not isinstance(listing, list):
        return {}
    return {
        str(r["full_name"]).split("/")[-1]: r
        for r in listing
        if isinstance(r, dict) and "/mgf-" in str(r.get("full_name", ""))
    }


# --------------------------------------------------------------------------- #
# CHECK: fleet — declared ↔ committed/disk
# --------------------------------------------------------------------------- #


def check_fleet(root: Path, **_: Any) -> CheckResult:
    """Every repo in the family registries exists on disk and is named in FLEET.md.

    ``FLEET.md`` lives in the private court workspace (``mgf/``), which is not a
    federation member — so an absent workspace is a legitimate SKIP rather than a
    failure of the fleet. Ported from ``mgf/tools/verify_fleet.py``.
    """
    res = CheckResult("fleet", (DECLARED, COMMITTED))
    fleet_md = root / "mgf" / "FLEET.md"
    if not fleet_md.is_file():
        res.skipped = f"no {fleet_md} (the court workspace is not checked out here)"
        return res
    fleet_text = fleet_md.read_text(encoding="utf-8")
    registries = [
        root / "mgf-standard" / "MGF_LIBRARIES.json",
        root / "sooth-standard" / "SOOTH_LIBRARIES.json",
    ]
    seen_any = False
    for reg in registries:
        if not reg.is_file():
            continue  # the sibling family may not be checked out — not this fleet's fault
        seen_any = True
        for name in sorted(_registry_names(reg)):
            res.examined += 1
            if not (root / name).is_dir():
                res.findings.append(
                    Finding(name, f"in {reg.name} but not on disk at {root / name}")
                )
            if name not in fleet_text:
                res.findings.append(Finding(name, f"in {reg.name} but not named in FLEET.md"))
    if not seen_any:
        res.skipped = "no family registry on disk"
    return res


def _registry_names(path: Path) -> set[str]:
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError):
        return set()
    items = (
        data
        if isinstance(data, list)
        else next((v for v in data.values() if isinstance(v, list)), [])
    )
    out = set()
    for item in items:
        if isinstance(item, dict):
            name = item.get("repo") or item.get("name") or item.get("slug")
            if name:
                out.add(str(name))
    return out


# --------------------------------------------------------------------------- #
# CHECK: governance — live(forge/CI) ↔ declared(registry)
# --------------------------------------------------------------------------- #

#: Explicitly exempt: on the forge with a pipeline, deliberately NOT a library. Each
#: MUST carry a reason. The point of this table is that an exemption is EXPLICIT and
#: REVIEWED — never silent. Carrying both an exemption and a registry membership is a
#: contradiction, and the check flags it.
GOVERNANCE_EXEMPT: dict[str, str] = {
    "mgf-secrets-store": (
        "the age-encrypted credential vault. It ships no code and no package — it is "
        "ciphertext plus a manifest, and its CI only proves the store is well-formed. "
        "Making it a registry member would imply a release surface it does not have; "
        "leaving it unlisted made it SILENTLY ungoverned."
    ),
}


def check_governance(root: Path, *, token: str | None = None, **_: Any) -> CheckResult:
    """Every repo on the forge is either a registry member or explicitly exempt.

    There is no silent third option: a repo can exist, have a green pipeline, and be
    invisible to every registry-scoped tool (``scaffold``, ``catch-up``,
    ``drift-check``, the conformance audit). Ported from ``verify_governance.py``.
    """
    res = CheckResult("governance", (LIVE, DECLARED))
    if token is None:
        res.skipped = "no Woodpecker token (set $WOODPECKER_TOKEN; mint per MGF_INFRA §2)"
        return res
    try:
        active = set(_wp_mgf_repos(token))
    except (urllib.error.URLError, OSError, ValueError) as exc:
        res.skipped = f"Woodpecker unreachable: {type(exc).__name__}"
        return res
    members = _registry_names(root / "mgf-standard" / "MGF_LIBRARIES.json")
    res.examined = len(active)
    for name in sorted(active - members - set(GOVERNANCE_EXEMPT)):
        res.findings.append(
            Finding(
                name,
                "on the forge with a pipeline, but neither a registry member nor exempt — "
                "silently ungoverned. Add it to MGF_LIBRARIES.json (a wave then sweeps it) "
                "or to GOVERNANCE_EXEMPT with a reason.",
            )
        )
    for name in sorted(set(GOVERNANCE_EXEMPT) & members):
        res.findings.append(
            Finding(name, "exempt AND a registry member — membership wins; drop the exemption")
        )
    for name in sorted(set(GOVERNANCE_EXEMPT) - active):
        res.findings.append(Finding(name, "exempt for a repo no longer on the forge — prune it"))
    return res


# --------------------------------------------------------------------------- #
# CHECK: ci-freshness — live(Woodpecker) ↔ committed(origin/main)
# --------------------------------------------------------------------------- #

#: main may be this many commits past the newest build. 0 = the newest build must be
#: ON the newest commit.
STALE_AFTER_COMMITS = 0


def check_ci_freshness(
    root: Path, *, token: str | None = None, fetch: bool = True, **_: Any
) -> CheckResult:
    """Every repo's newest CI build corresponds to its newest commit on origin/main.

    **"active" is not "receiving events".** ``mgf-hrb-rt`` reported ``active: true``
    in Woodpecker and had zero CI for three weeks — a dead webhook. Sixteen commits
    and two PR merges landed on main behind it. A dead webhook looks EXACTLY like a
    healthy repo from the registry's side. Ported from ``verify_ci_freshness.py``.
    """
    res = CheckResult("ci-freshness", (LIVE, COMMITTED))
    if token is None:
        res.skipped = "no Woodpecker token (set $WOODPECKER_TOKEN; mint per MGF_INFRA §2)"
        return res
    try:
        repos = _wp_mgf_repos(token)
    except (urllib.error.URLError, OSError, ValueError) as exc:
        res.skipped = f"Woodpecker unreachable: {type(exc).__name__}"
        return res

    for name, meta in sorted(repos.items()):
        clone = root / name
        if not (clone / ".git").is_dir():
            continue
        # Fetch FIRST. A stale local clone makes this check report phantom staleness —
        # which would be this gate's own footgun, inside the gate built to catch it.
        if fetch:
            _git(clone, "fetch", "--quiet", "origin")
        head = _git(clone, "rev-parse", "origin/main")
        if not head:
            continue
        res.examined += 1
        try:
            pipelines = _wp(f"/repos/{meta['id']}/pipelines?limit=25", token)
        except (urllib.error.URLError, OSError, ValueError):
            res.findings.append(Finding(name, "could not read its pipelines — treat as unverified"))
            continue
        mains = [
            p
            for p in (pipelines if isinstance(pipelines, list) else [])
            if isinstance(p, dict)
            and p.get("branch") == "main"
            and p.get("event") in ("push", "manual", "cron")
        ]
        if not mains:
            res.findings.append(
                Finding(name, f"NO build of main has EVER run (active={meta.get('active')})")
            )
            continue
        newest = mains[0]
        built = str(newest.get("commit") or "")
        if built == head:
            continue
        behind = _git(clone, "rev-list", "--count", f"{built}..{head}") if built else ""
        if not behind.isdigit():
            res.findings.append(
                Finding(
                    name, f"newest build #{newest.get('number')} is on unknown commit {built[:8]}"
                )
            )
        elif int(behind) > STALE_AFTER_COMMITS:
            res.findings.append(
                Finding(
                    name,
                    f"main is {behind} commit(s) PAST the newest build "
                    f"(#{newest.get('number')} on {built[:8]}, active={meta.get('active')}) — "
                    f"a dead webhook looks exactly like this. Repair: "
                    f"POST /repos/{meta['id']}/repair",
                )
            )
    return res


# --------------------------------------------------------------------------- #
# CHECK: release-currency — committed(main) ↔ published(devpi)
# --------------------------------------------------------------------------- #

# Changes to these never reach a consumer's installed wheel — a wheel is src/ plus
# declared package-data, nothing else. Every member builds
# `[tool.hatch.build.targets.wheel] packages = ["src/mgf"]` (verified fleet-wide), so
# tests/, ops/, examples/, generated fed-resources state, repo scripts, uv.lock and the
# governance docs cannot reach a consumer.
#
# This list is TUNED, not conservative: without it the gate reported 28 distributions
# "whose declared version lies" and 26 were noise (PAPER-130). **A gate that cries wolf
# 26 times is how the two REAL findings get ignored — over-reporting is a correctness
# bug in a gate, not a safe default.** PUBLIC_API.json is generated FROM src/, so a
# change there without a src/ change is a regeneration, not new shipped code.
RELEASE_IGNORED = re.compile(
    r"^(docs/|\.claude/|\.githooks/|court/|CHANGELOG\.md|FEEDBACK\.md|README\.md|"
    r"LESSON_LEARNED\.md|pending-feedback/|\.woodpecker\.yml|\.gitignore|\.gitmessage|"
    r"MGF_STANDARDS_CONFORMANCE\.md|docs/inprogress/|tests/|ops/|examples/|\.gates/|"
    r"\.gitleaks\.toml|scripts/|CONTRIBUTING\.md|DEPS\.md|SCOPE\.md|ETHICS\.md|NOTICE|"
    r"uv\.lock|fed-resources/|MGF_MEMBER\.toml|PUBLIC_API\.json|AGENTS\.md|AUDIT\.json)"
)
# `AUDIT.json` was added ONE HOUR LATER, by the same gate, for the same reason: this
# module's own committed artifact is a repo-root governance file that no wheel ships, and
# leaving it out made the audit report the repo that stores its results. Two entries in one
# session, from opposite directions, is the rule stated as plainly as it can be — **an
# exclusion list decays**, and it decays fastest right after you add something to the repo.
# `AGENTS.md` was added by THIS gate's first live run (2026-07-30): it reported 32 of 33
# distributions stale, and 25 of those said only "shipped files changed: AGENTS.md". The
# fleet-wide AGENTS.md ripple postdated the list above, so a faithful port inherited a list
# that had gone stale — the same over-reporting the list was written to prevent, arriving by
# a different route. A gate's exclusion list is not write-once; it decays as the repo shape
# changes, and the decay shows up as noise long before anyone calls it a bug.
#
# `__pycache__` is excluded from the SOURCE question for the same reason — a compiled artifact
# is never shipped source. It being *tracked in git* is a genuine defect (mgf-brand-qt commits
# 20 .pyc files under src/), but it is a repo-hygiene defect, and answering it here would
# answer it in the wrong gate's voice.
_PYCACHE = re.compile(r"(^|/)__pycache__/")


def check_release_currency(root: Path, *, offline: bool = False, **_: Any) -> CheckResult:
    """A version number must mean something.

    ``mgf-fed registry --check`` compares a repo's DECLARED version to the REGISTRY's —
    both said ``0.4.10`` and it passed, while main sat 28 commits past the ``v0.4.10``
    tag including a relicense. The published ``0.4.10`` and main's ``0.4.10`` were
    different code under the same number.

    **A tag is not a release.** ``mgf-vm-libvirt`` had ``v0.1.11`` tagged and the
    artifact never reached devpi — its ``smoke`` step failed before ``publish`` ever
    ran. So this asks the INDEX, not the tag: a tag says what we meant to ship.

    Ported from ``verify_release_currency.py``.
    """
    res = CheckResult("release-currency", (COMMITTED, PUBLISHED))
    for repo in _repos_on_disk(root):
        version = _declared_version(repo)
        if not version:
            continue  # not a Python distribution
        res.examined += 1
        tag = f"v{version}"
        if not _git(repo, "tag", "-l", tag):
            res.findings.append(
                Finding(repo.name, f"declares {version} but no tag {tag} exists — never released")
            )
            continue
        if not offline:
            pubs = _published_versions(repo.name)
            if pubs is not None and version not in pubs:
                newest = _newest(pubs)
                res.findings.append(
                    Finding(
                        repo.name,
                        f"v{version} is TAGGED but devpi does not serve it "
                        f"(newest published: {newest}) — the publish step never ran",
                    )
                )
                continue
        changed = [
            f
            for f in _git(repo, "diff", "--name-only", f"{tag}..origin/main").splitlines()
            if f and not RELEASE_IGNORED.match(f) and not _PYCACHE.search(f)
        ]
        if not changed:
            continue
        n = _git(repo, "rev-list", "--count", f"{tag}..origin/main")
        preview = ", ".join(changed[:3]) + (
            f", +{len(changed) - 3} more" if len(changed) > 3 else ""
        )
        res.findings.append(
            Finding(
                repo.name,
                f"main is {n} commit(s) past {tag} and STILL declares {version} — "
                f"the published {version} is different code. Shipped files changed: {preview}",
            )
        )
    return res


def _declared_version(repo: Path) -> str | None:
    txt = _git(repo, "show", "origin/main:pyproject.toml")
    m = re.search(r'^version\s*=\s*["\']([^"\']+)["\']', txt, re.M)
    return m.group(1) if m else None


def _published_versions(name: str, index: str = DEVPI) -> set[str] | None:
    """What the index ACTUALLY serves. None when the index is unreachable — the
    caller must not read that as "nothing published"."""
    # The simple index is HTML, not JSON, so this does not go through request_json.
    try:
        with urllib.request.urlopen(  # noqa: S310 — fixed index host
            index.rstrip("/") + "/" + name + "/", timeout=25
        ) as resp:
            body = resp.read().decode()
    except (urllib.error.URLError, OSError, ValueError):
        return None
    return set(re.findall(rf"{name.replace('-', '_')}-([0-9][0-9a-z.]*)-py3", body))


def _newest(versions: set[str]) -> str:
    def key(v: str) -> tuple[int, ...]:
        try:
            return tuple(int(x) for x in v.split("."))
        except ValueError:
            return (0,)

    return max(versions, key=key) if versions else "—"


# --------------------------------------------------------------------------- #
# CHECK: installed-currency — installed ↔ published
# --------------------------------------------------------------------------- #


def check_installed_currency(root: Path, *, offline: bool = False, **_: Any) -> CheckResult:
    """What the operator RUNS versus what the index serves.

    The layer nothing was watching. On 2026-07-30 a lockout-shaped ``rotate`` defect
    was fixed and merged, and the operator's ``mgf-secrets`` stayed at the published
    0.5.2 — so the hazard was live in the command anyone would actually type, with
    the fix sitting in main looking done.

    Read-only, and deliberately narrow: it judges only the console tools that are
    actually installed. A tool the operator has not installed is not a finding.
    """
    res = CheckResult("installed-currency", (INSTALLED, PUBLISHED))
    if offline:
        res.skipped = "--offline (needs the index to know what is published)"
        return res
    installed = _installed_tools()
    if installed is None:
        res.skipped = "`uv tool list` unavailable — cannot see the installed layer"
        return res
    for name, version in sorted(installed.items()):
        if not name.startswith("mgf-"):
            continue
        res.examined += 1
        pubs = _published_versions(name)
        if pubs is None:
            res.findings.append(Finding(name, "index unreachable — installed version unverified"))
            continue
        if not pubs or version in pubs:
            newest = _newest(pubs)
            if pubs and version != newest:
                res.findings.append(
                    Finding(
                        name,
                        f"installed {version}, index serves {newest} — "
                        f"`uv tool upgrade {name}` (a fix that is published but not "
                        f"installed has not reached you)",
                    )
                )
            continue
        res.findings.append(
            Finding(name, f"installed {version} is not on the index (newest: {_newest(pubs)})")
        )
    return res


def _installed_tools() -> dict[str, str] | None:
    """``{tool: version}`` from ``uv tool list``. None when uv is unavailable."""
    try:
        out = subprocess.run(
            ["uv", "tool", "list"],  # noqa: S607 — `uv` from PATH, as the fleet invokes it
            capture_output=True,
            text=True,
            timeout=60,
            check=False,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    if out.returncode != 0:
        return None
    tools: dict[str, str] = {}
    for line in out.stdout.splitlines():
        m = re.match(r"^(\S+)\s+v(\S+)", line)
        if m:
            tools[m.group(1)] = m.group(2)
    return tools


# --------------------------------------------------------------------------- #
# CHECK: secret-scopes — declared(SECRETS.toml/live scopes) ↔ committed(pipeline)
# --------------------------------------------------------------------------- #

_ALL_EVENTS = frozenset({"push", "pull_request", "tag", "cron", "manual", "deployment"})

#: Publish credentials are deliberately ``tag``-only. Least privilege is the design,
#: not a bug: if a repo asks for one outside its allowed events, fix the REPO. This
#: check must never recommend widening these.
LEAST_PRIVILEGE = frozenset(
    {"codeberg_package_token", "codeberg_package_user", "devpi_password", "woodpecker_token"}
)

#: HELD deploy paths — deliberately NOT armed, and this check ENFORCES that.
#: While a path is held, the secrets being unavailable is a PASS and **arming them is
#: a FAILURE**. Reporting a held path as 13 red gaps forever would make this a gate
#: everyone learns to ignore, which is the failure it exists to prevent.
HELD_PATHS: dict[str, frozenset[str]] = {
    "mgf-website": frozenset(
        {
            "cloudflare_account_id",
            "cloudflare_api_token",
            "contact_form_endpoint",
            "resend_api_key",
            "turnstile_secret_key",
            "turnstile_site_key",
            "infomaniak_ssh_host",
            "infomaniak_ssh_key",
            "infomaniak_ssh_path",
            "infomaniak_ssh_port",
            "infomaniak_ssh_user",
            "codeberg_ssh_clone_key",
            "codeberg_ssh_host_key",
        }
    ),
}
HELD_EVENTS = frozenset({"deployment"})


def check_secret_scopes(root: Path, *, token: str | None = None, **_: Any) -> CheckResult:
    """Every secret a pipeline requests is registered and allowed for every event the
    requesting step runs on.

    Woodpecker resolves ``from_secret`` when it COMPILES a pipeline: an unregistered
    or out-of-scope secret kills the run at 0 seconds with no logs. Ported from
    ``verify_secret_scopes.py``.
    """
    res = CheckResult("secret-scopes", (DECLARED, COMMITTED))
    try:
        import yaml
    except ImportError:
        # mgf-fed carries no runtime dependencies by design; PyYAML rides in the dev
        # extra. An honest skip beats either a new fleet-wide dep or a line-based
        # parser that would silently miss nested `from_secret:` keys.
        res.skipped = "PyYAML unavailable (mgf-fed has no runtime deps; install the dev extra)"
        return res
    if token is None:
        res.skipped = "no Woodpecker token — cannot read the registered secret scopes"
        return res
    try:
        org_secrets = _wp("/orgs/1/secrets", token)
        ids = {k: v["id"] for k, v in _wp_mgf_repos(token).items()}
    except (urllib.error.URLError, OSError, ValueError, KeyError) as exc:
        res.skipped = f"Woodpecker unreachable: {type(exc).__name__}"
        return res
    org = {
        str(s["name"]): set(s.get("events", []))
        for s in (org_secrets if isinstance(org_secrets, list) else [])
        if isinstance(s, dict) and "name" in s
    }

    for repo in _repos_on_disk(root):
        raw = _git(repo, "show", "origin/main:.woodpecker.yml")
        if not raw:
            continue
        try:
            doc = yaml.safe_load(raw)
        except yaml.YAMLError as exc:
            res.findings.append(Finding(repo.name, f"UNPARSEABLE .woodpecker.yml: {exc}"))
            continue
        if not isinstance(doc, dict):
            continue
        res.examined += 1
        known = dict(org)
        if repo.name in ids:
            try:
                repo_secrets = _wp(f"/repos/{ids[repo.name]}/secrets", token)
                for s in repo_secrets if isinstance(repo_secrets, list) else []:
                    if isinstance(s, dict) and "name" in s:
                        known[str(s["name"])] = set(s.get("events", []))
            except (urllib.error.URLError, OSError, ValueError):
                pass  # org scopes still judge it; a repo-secret read failure is not a gap
        res.findings.extend(_scope_findings(repo.name, doc, known))
    return res


def _scope_findings(repo: str, doc: dict[str, Any], known: dict[str, set[str]]) -> list[Finding]:
    out: list[Finding] = []
    held = HELD_PATHS.get(repo, frozenset())
    for name, needed in sorted(_secret_demands(doc).items()):
        allowed = known.get(name)
        if name in held and needed & HELD_EVENTS:
            if allowed is not None and (needed & HELD_EVENTS) <= allowed:
                out.append(
                    Finding(
                        f"{repo}:{name}",
                        f"a HELD deploy path has been ARMED on {sorted(needed & HELD_EVENTS)} — "
                        f"registering it PUBLISHES THE SITE. Un-hold deliberately, in a "
                        f"reviewed commit; never by quietly registering a secret.",
                    )
                )
            needed = needed - HELD_EVENTS
            if not needed:
                continue
        if allowed is None:
            if name in held:
                continue  # unregistered *because* it is held — that is the intended state
            out.append(
                Finding(f"{repo}:{name}", "not registered — the pipeline dies at COMPILE (no logs)")
            )
            continue
        missing = needed - allowed
        if not missing:
            continue
        if name in LEAST_PRIVILEGE:
            out.append(
                Finding(
                    f"{repo}:{name}",
                    f"requested on {sorted(missing)} — fix the REPO, never widen the secret",
                )
            )
        else:
            out.append(
                Finding(
                    f"{repo}:{name}",
                    f"not allowed on {sorted(missing)} — the pipeline dies at "
                    f"COMPILE on that event",
                )
            )
    return out


def _when_events(node: object, default: frozenset[str]) -> frozenset[str]:
    """The events a ``when:`` block admits. No ``event:`` key ⇒ no event restriction."""
    whens = node if isinstance(node, list) else [node]
    found: set[str] = set()
    saw_event_key = False
    for w in whens:
        if not isinstance(w, dict):
            continue
        ev = w.get("event")
        if ev is None:
            continue
        saw_event_key = True
        found |= {ev} if isinstance(ev, str) else set(ev)
    return frozenset(found & _ALL_EVENTS) if saw_event_key else default


def _secrets_in(node: object) -> set[str]:
    """Every ``from_secret:`` name anywhere under this node."""
    out: set[str] = set()
    if isinstance(node, dict):
        for k, v in node.items():
            if k == "from_secret" and isinstance(v, str):
                out.add(v)
            else:
                out |= _secrets_in(v)
    elif isinstance(node, list):
        for v in node:
            out |= _secrets_in(v)
    return out


def _secret_demands(doc: dict[str, Any]) -> dict[str, set[str]]:
    """``secret -> the events on which some step that runs will request it``."""
    pipeline_events = _when_events(doc.get("when", {}), frozenset(_ALL_EVENTS))
    req: dict[str, set[str]] = {}

    def add(node: object, events: Iterable[str]) -> None:
        for name in _secrets_in(node):
            req.setdefault(name, set()).update(events)

    # clone + services run on every event the pipeline runs on — never pruned.
    add(doc.get("clone"), pipeline_events)
    add(doc.get("services"), pipeline_events)
    steps = doc.get("steps")
    items = steps.values() if isinstance(steps, dict) else (steps or [])
    for step in items:
        if isinstance(step, dict):
            add(step, _when_events(step.get("when", {}), pipeline_events) & pipeline_events)
    return req


# --------------------------------------------------------------------------- #
# CHECK: feedback-status — declared(FEEDBACK.md) hygiene
# --------------------------------------------------------------------------- #

_PAPER_ID = re.compile(r"`?\b([A-Z][A-Z-]{1,14}-\d+)\b`?")
_TEMPLATE = re.compile(
    r"<one-line title>|<AREA>-<NN>|PAPER-NN|<title>|\*placeholder|original-concern|"
    r"^\d+\.\d+ |^Quick-start|^Submission steps|^What makes|^Don't worry|^Entry (shape|template)|"
    r"^Area prefixes|^Paper shape|^💭|^🟡 Papercuts",
    re.I,
)
# `[^A-Za-z|\n]*` skips a leading status emoji — the fleet writes `✅ SHIPPED`,
# `🟡 PARTIALLY SHIPPED`. Anchoring straight to a letter reported every emoji-prefixed
# status as absent, i.e. it called resolved papers unstated: the same over-reporting
# class that made the release gate cry wolf 26 times.
_STATUS_BULLET = re.compile(r"\*\*Status:?\*\*:?\s*[^A-Za-z\n]*([A-Za-z-]+)")
_STATUS_TABLE = re.compile(r"\|\s*Status\s*\|\s*[^A-Za-z|\n]*([A-Za-z-]+)")


def check_feedback_status(root: Path, **_: Any) -> CheckResult:
    """Every paper in a FEEDBACK.md states a status.

    The 2026-07-24 review found the queue lying in BOTH directions — papers marked
    OPEN that had shipped weeks earlier, and papers marked ACCEPTED with zero
    implementation. Whether a status is TRUE is a judgement; whether one EXISTS is
    mechanical, and an unstated status cannot be trusted either way. Ported from
    ``feedback_census.py``, reduced to its gateable assertion.
    """
    res = CheckResult("feedback-status", (DECLARED, DECLARED))
    for fb in sorted(root.glob("mgf-*/FEEDBACK.md")):
        for heading, body in _papers(fb.read_text(encoding="utf-8")):
            pid = _PAPER_ID.search(heading)
            if not pid or _TEMPLATE.search(heading) or _is_range_heading(heading, pid):
                continue
            res.examined += 1
            if not (_STATUS_BULLET.search(body) or _STATUS_TABLE.search(body)):
                res.findings.append(
                    Finding(
                        f"{fb.parent.name}:{pid.group(1)}",
                        "no Status field — an unstated status cannot be trusted in either "
                        "direction, so the paper is invisible to any queue sweep",
                    )
                )
    return res


def _is_range_heading(heading: str, match: re.Match[str]) -> bool:
    """True when the matched id is the START OF A RANGE — a cohort heading, not a paper.

    ``## Plasma project feedback cohort — filed 2026-06-11 (PAPER-100..106)`` groups
    seven papers that each carry their own ``###`` heading below it; the cohort summary
    has no Status field of its own and never should.

    The test is deliberately narrow: the id must be *immediately* followed by ``..``.
    ``### PAPER-100 — Index of MUSTs omits 5 families + REL-10..13`` is a REAL paper that
    merely mentions a range of a different prefix, and a looser "contains a range" rule
    would silently drop it. This distinction is why the check is stricter than the census
    it was ported from: a noisy row in a report is tolerable, and a noisy row in a GATE is
    how the real findings get ignored.
    """
    return heading[match.span(1)[1] : match.span(1)[1] + 2] == ".."


def _papers(text: str) -> list[tuple[str, str]]:
    """``(heading, body)`` per ``##``/``###`` section."""
    out: list[tuple[str, str]] = []
    heading: str = ""
    buf: list[str] = []
    for line in text.splitlines():
        if line.startswith(("## ", "### ")):
            if heading:
                out.append((heading, "\n".join(buf)))
            heading, buf = line.lstrip("# ").strip(), []
        else:
            buf.append(line)
    if heading:
        out.append((heading, "\n".join(buf)))
    return out


# --------------------------------------------------------------------------- #
# CHECK: pypi-namespace — published-external(pypi.org) ↔ declared(registry)
# --------------------------------------------------------------------------- #


def check_pypi_namespace(root: Path, *, offline: bool = False, **_: Any) -> CheckResult:
    """The ``mgf-*`` public-PyPI namespace is CHECKED, not assumed.

    PKG-06 justified the prefix as "a private convention no public PyPI package uses".
    That was false: our own distributions were live on pypi.org, mislicensed MIT
    against Apache-2.0 source. A name we do not hold is a name someone else can take,
    and a consumer who typos an index gets THEIR package.
    """
    res = CheckResult("pypi-namespace", (PUBLISHED, DECLARED))
    if offline:
        res.skipped = "--offline (needs pypi.org)"
        return res
    names = _registry_names(root / "mgf-standard" / "MGF_LIBRARIES.json")
    if not names:
        res.skipped = "no MGF_LIBRARIES.json on disk"
        return res
    import urllib.request

    for name in sorted(names):
        res.examined += 1
        try:
            with urllib.request.urlopen(f"https://pypi.org/pypi/{name}/json", timeout=20) as resp:
                live = resp.status == 200
        except urllib.error.HTTPError as exc:
            if exc.code == 404:
                continue  # unclaimed — the expected state
            res.findings.append(Finding(name, f"pypi.org returned HTTP {exc.code} — unverified"))
            continue
        except (urllib.error.URLError, OSError):
            res.findings.append(Finding(name, "pypi.org unreachable — namespace unverified"))
            continue
        if live:
            res.findings.append(
                Finding(
                    name,
                    "LIVE on public pypi.org — a fleet name is publicly resolvable, so a "
                    "consumer pointed at the wrong index installs it. Confirm we own it and "
                    "that its license matches the source.",
                )
            )
    return res


# --------------------------------------------------------------------------- #
# The registry of checks + the runner.
# --------------------------------------------------------------------------- #

#: ``name -> (function, needs_network)``. A check that needs the network is SKIPPED
#: under ``--offline`` and, without it, a failure to reach the surface is a finding or
#: an un-requested skip — never a silent pass.
CHECKS: dict[str, tuple[Callable[..., CheckResult], bool, tuple[str, str]]] = {
    "fleet": (check_fleet, False, (DECLARED, COMMITTED)),
    "feedback-status": (check_feedback_status, False, (DECLARED, DECLARED)),
    "release-currency": (check_release_currency, True, (COMMITTED, PUBLISHED)),
    "installed-currency": (check_installed_currency, True, (INSTALLED, PUBLISHED)),
    "governance": (check_governance, True, (LIVE, DECLARED)),
    "ci-freshness": (check_ci_freshness, True, (LIVE, COMMITTED)),
    "secret-scopes": (check_secret_scopes, True, (DECLARED, COMMITTED)),
    "pypi-namespace": (check_pypi_namespace, True, (PUBLISHED, DECLARED)),
}


def run_checks(
    names: Sequence[str] | None = None,
    *,
    offline: bool = False,
    root: Path | None = None,
    token: str | None = None,
) -> list[CheckResult]:
    root = root or federation_root()
    wanted = list(names) if names else list(CHECKS)
    tok = None if offline else _wp_token(token)
    out: list[CheckResult] = []
    for name in wanted:
        fn, needs_net, layers = CHECKS[name]
        if offline and needs_net:
            # A skipped check still REPORTS its boundary: a skip that cannot say what
            # it would have compared is exactly as opaque as no check at all.
            out.append(CheckResult(name, layers, skipped="--offline"))
            continue
        out.append(fn(root, offline=offline, token=tok))
    return out


#: The committed artifact. It lives in mgf-standard because that is the repo whose
#: pipeline gates it — the audit needs the whole federation on disk plus credentials,
#: so it CANNOT run inside a single-repo CI clone. The expensive half runs where the
#: fleet is; the cheap half — "is that artifact fresh, complete and clean?" — rides the
#: ordinary pipeline and needs no clones, no network and no credentials.
AUDIT_ARTIFACT = Path("mgf-standard") / "AUDIT.json"
MAX_AUDIT_AGE_DAYS = 14


def write_artifact(results: list[CheckResult], path: Path, *, when: str) -> None:
    """Record the run, INCLUDING what did not run. A skipped check is written down as
    skipped so the artifact can never be mistaken for a full audit."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(
        json.dumps(
            {
                "generated": when,
                "checks": [r.to_json() for r in results],
                "findings": sum(len(r.findings) for r in results),
                "skipped": [r.name for r in results if r.skipped],
                "ran": [r.name for r in results if not r.skipped],
            },
            indent=2,
            sort_keys=True,
        )
        + "\n",
        encoding="utf-8",
    )


def check_artifact(path: Path | None = None, max_age_days: int = MAX_AUDIT_AGE_DAYS) -> int:
    """Gate the COMMITTED artifact — the half that rides CI.

    It re-audits nothing. It asks the artifact four questions it can answer about
    itself, and every one of them is a way a checker has actually died here:

    1. **Does it exist, and is it stamped?** An unstamped artifact has no age, so it
       can never be called stale.
    2. **Is it younger than the limit?** On 2026-07-29 the committed conformance
       tracker was found still asserting a result from 2026-07-04 — twenty-five days
       of a stale artifact reading as current, because nothing was ever going to look.
    3. **Did every check actually RUN?** An artifact from an ``--offline`` run has
       real numbers and covers half the boundaries. Zero findings across the checks
       that ran is not a clean audit.
    4. **Was it clean?**

    A checker nobody runs silently becomes a liar; this is what stops that.
    """
    path = path or (federation_root() / AUDIT_ARTIFACT)
    if not path.is_file():
        print(f"AUDIT ARTIFACT — MISSING at {path}")
        print("  run `mgf-fed audit --write` at the federation root and commit the result")
        return 1
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:
        print(f"AUDIT ARTIFACT — UNREADABLE: {exc}")
        return 1
    stamp = str(data.get("generated") or "")
    if not stamp:
        print("AUDIT ARTIFACT — UNSTAMPED (an artifact with no date can never go stale)")
        return 1
    age = _age_days(stamp)
    problems: list[str] = []
    if age is None:
        problems.append(f"unparseable stamp {stamp!r}")
    elif age > max_age_days:
        problems.append(f"STALE — generated {stamp} ({age} days old, limit {max_age_days})")
    skipped = list(data.get("skipped") or [])
    if skipped:
        problems.append(
            f"INCOMPLETE — {len(skipped)} check(s) did not run ({', '.join(skipped)}); "
            f"zero findings across the checks that DID run is not a clean audit"
        )
    findings = int(data.get("findings") or 0)
    if findings:
        problems.append(f"{findings} unresolved cross-layer finding(s)")
    print(f"AUDIT ARTIFACT — generated {stamp}, {len(data.get('ran') or [])} check(s) ran")
    if problems:
        for pr in problems:
            print(f"  - {pr}")
        print("FAIL — re-run `mgf-fed audit --write` at the federation root and commit it")
        return 1
    print(f"PASS — fresh (≤{max_age_days}d), complete, and clean")
    return 0


def _today() -> str:
    from datetime import UTC, datetime

    return datetime.now(UTC).strftime("%Y-%m-%d")


def _age_days(stamp: str) -> int | None:
    from datetime import UTC, datetime

    try:
        then = datetime.strptime(stamp[:10], "%Y-%m-%d").replace(tzinfo=UTC)
    except ValueError:
        return None
    return (datetime.now(UTC) - then).days


def run_audit(
    names: Sequence[str] | None = None,
    *,
    offline: bool = False,
    json_only: bool = False,
    root: Path | None = None,
    token: str | None = None,
    write: Path | None = None,
    when: str | None = None,
) -> int:
    """Run the cross-layer audit. Returns the process exit code.

    **0 only when every requested check RAN and found nothing.** An un-requested skip
    (no token, unreachable index) exits 1: "I could not verify" must never share an
    exit code with "I verified". Under ``--offline`` the network checks are skipped by
    request, so they do not fail the run — but they are still named in the output,
    because a check that did not run is not a check that passed.
    """
    results = run_checks(names, offline=offline, root=root, token=token)
    if write is not None:
        write_artifact(results, write, when=when or _today())
    if json_only:
        print(
            json.dumps(
                {
                    "offline": offline,
                    "checks": [r.to_json() for r in results],
                    "findings": sum(len(r.findings) for r in results),
                    "skipped": [r.name for r in results if r.skipped],
                },
                indent=2,
                sort_keys=True,
            )
        )
    else:
        _render(results, offline=offline)
    findings = sum(len(r.findings) for r in results)
    unrequested_skips = [r for r in results if r.skipped and r.skipped != "--offline"]
    return 1 if (findings or unrequested_skips) else 0


def _render(results: list[CheckResult], *, offline: bool) -> None:
    print(
        f"mgf-fed audit — {len(results)} cross-layer check(s)" + (" [offline]" if offline else "")
    )
    print()
    for r in results:
        boundary = f"{r.layers[0]} ↔ {r.layers[1]}"
        if r.skipped:
            print(f"  ⏭  {r.name:<20} {boundary:<26} SKIPPED — {r.skipped}")
            continue
        mark = "✅" if r.ok else "🔴"
        print(
            f"  {mark} {r.name:<20} {boundary:<26} "
            f"{r.examined} examined, {len(r.findings)} finding(s)"
        )
        for f in r.findings:
            print(f"        · {f.target}: {f.detail}")
    print()
    findings = sum(len(r.findings) for r in results)
    skipped = [r for r in results if r.skipped]
    ran = len(results) - len(skipped)
    if skipped:
        # Never let a skip read as a pass. This sentence is the whole point of the
        # module: it states what was NOT checked, every single run.
        print(f"  {len(skipped)} check(s) did NOT run: {', '.join(r.name for r in skipped)}")
    if findings:
        print(f"FAIL — {findings} cross-layer finding(s) across {ran} check(s) that ran")
    elif any(r.skipped and r.skipped != "--offline" for r in results):
        print(f"FAIL — {ran} check(s) clean, but a check could not run (that is not a pass)")
    else:
        print(
            f"PASS — {ran} check(s) ran clean"
            + (f", {len(skipped)} skipped by request" if skipped else "")
        )


__all__ = [
    "AUDIT_ARTIFACT",
    "CHECKS",
    "COMMITTED",
    "DECLARED",
    "INSTALLED",
    "LIVE",
    "MAX_AUDIT_AGE_DAYS",
    "PUBLISHED",
    "CheckResult",
    "Finding",
    "check_artifact",
    "run_audit",
    "run_checks",
    "write_artifact",
]
