"""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 tomllib
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 _wheel_sources(pyproject_text: str) -> tuple[tuple[str, ...], tuple[str, ...]] | None:
    """What this repo's wheel ACTUALLY ships, read from its own build config.

    Returns ``(package dirs, force-include sources)`` from
    ``[tool.hatch.build.targets.wheel]``, or None when the config cannot be read —
    the caller then degrades to the exclusion list and says so.

    Why this replaced a hand-tuned list. ``RELEASE_IGNORED`` existed because we were
    GUESSING what ships ("a wheel is src/ plus package-data"). The build config is
    not a guess — it is the thing hatchling obeys. Two consequences, both observed:

    * the list **decayed** twice in one session (``AGENTS.md``, then ``AUDIT.json``),
      each time reporting files that never ship;
    * worse, it **under-reported**: ``mgf-standard`` force-includes ``docs/standards``
      into its wheel, and the list excludes ``docs/``, so a change to the standards
      corpus — the repo's entire product — would have shipped unnoticed.

    Over-reporting is noise. **Under-reporting is a gate that lies.** Reading the
    config fixes both directions at once, and deletes the reason the list existed.
    """
    try:
        data = tomllib.loads(pyproject_text)
    except (tomllib.TOMLDecodeError, ValueError):
        return None
    wheel = (
        data.get("tool", {}).get("hatch", {}).get("build", {}).get("targets", {}).get("wheel", {})
    )
    if not isinstance(wheel, dict):
        return None
    packages = tuple(str(x).strip("/") for x in (wheel.get("packages") or []))
    forced = tuple(str(k).strip("/") for k in (wheel.get("force-include") or {}))
    if not packages and not forced:
        return None  # not a hatchling wheel we can reason about
    return packages, forced


def _ships(path: str, packages: tuple[str, ...], forced: tuple[str, ...]) -> bool:
    """True when ``path`` lands in the wheel: under a package dir, or force-included."""
    for pkg in packages:
        if path == pkg or path.startswith(pkg + "/"):
            return True
    for src in forced:
        if path == src or path.startswith(src + "/"):
            return True
    return False


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
        diff = [
            f for f in _git(repo, "diff", "--name-only", f"{tag}..origin/main").splitlines() if f
        ]
        sources = _wheel_sources(_git(repo, "show", "origin/main:pyproject.toml"))
        if sources is None:
            # Degraded path: no readable wheel config, so fall back to the guess.
            changed = [f for f in diff if not RELEASE_IGNORED.match(f)]
            how = " (judged by the exclusion list — no readable wheel config)"
        else:
            changed = [f for f in diff if _ships(f, *sources)]
            how = ""
        changed = [f for f in changed if 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: "
                f"{preview}{how}",
            )
        )
    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, where = _resolve_artifact(path)
    if not path.is_file():
        print(f"AUDIT ARTIFACT — MISSING at {path} ({where})")
        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 "
        f"[read from {where}]"
    )
    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 _resolve_artifact(explicit: Path | None) -> tuple[Path, str]:
    """Find the committed artifact, and NAME where it was found.

    Two contexts, and the gate has to work in both:

    * **A single-repo CI clone** — the repo IS the working directory, so the
      artifact sits at ``./AUDIT.json``. There is no federation root; ``$HOME`` is a
      container's, and ``federation_root()`` resolves to a path that does not exist.
    * **A federation-root run** — the artifact is at ``mgf-standard/AUDIT.json``.

    The first version resolved only the second, so the gate passed at the federation
    root and reported MISSING in the pipeline it was written for. It was verified in
    the wrong environment: a module about layer confusion, confusing two contexts.

    The return value carries WHERE it looked, and the caller prints it — an answer
    that cannot say which context produced it is the defect this module exists to end.
    """
    if explicit is not None:
        return explicit, "explicit --root"
    here = Path.cwd() / "AUDIT.json"
    if here.is_file():
        return here, "cwd — single-repo checkout"
    return federation_root() / AUDIT_ARTIFACT, "federation root"


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",
]
