"""The read-only doctor — proves declared == deployed == consumed (P1 checks C1-C8).

**Strictly read-only against the world** (SPEC §1): the only inputs are the
registry, committed pipelines under the federation root, and read-only API/SSH
probes. Every finding carries evidence and NO secret value (AG-18) — C6 decodes a
JWT ``exp`` in-process but emits only the integer, never the token.

Checks are gated by the requested surfaces (default: woodpecker + local):
  woodpecker → C1 C2 C3(secrets) C4 C8 · forge → C5 C3(deploy-keys) ·
  local → C6 · hosts → C7.
"""

from __future__ import annotations

import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING, Any

from .errors import CredentialError, SurfaceUnavailableError
from .fsutil import jwt_exp as _jwt_exp
from .fsutil import nonblank_lines as _nonblank_lines
from .fsutil import octal_mode as _octal
from .managed import ManagedResolution, normalize_repos, resolve_all_managed_detail
from .pipeline import audit_refs, parse_pipeline
from .woodpecker import ERROR_STATUSES

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

    from .forge import ForgeAPI
    from .hosts import HostAPI
    from .model import Entry
    from .registry import Registry
    from .woodpecker import WoodpeckerAPI

DEFAULT_SURFACES = ("woodpecker", "local")
ALL_SURFACES = ("woodpecker", "forge", "local", "hosts")

#: Where the consumed-check (C2/C4/C8) reads each activated repo's pipeline text:
#: ``local`` = the committed ``.woodpecker.yml`` under the federation root (the
#: drift-check pattern); ``forge-raw`` = the RAW COMMITTED file fetched from the forge
#: contents API (the no-checkout mode for agent2, SPEC-P4 §1). The CONTRACT is
#: ``forge-raw == local``: both see the FULL committed pipeline (all events + every
#: ``from_secret``). Woodpecker's per-pipeline config is EVENT-RENDERED (it strips
#: ``from_secret`` + event-filtered steps) and would false-clean the check — so it is
#: NOT used. ``forge-raw`` is Codeberg-budgeted (§7b): cached, bounded, degrades to
#: ``unverifiable`` on 429, never silently 0.
CONFIG_SOURCE_LOCAL = "local"
CONFIG_SOURCE_FORGE_RAW = "forge-raw"
CONFIG_SOURCES = (CONFIG_SOURCE_LOCAL, CONFIG_SOURCE_FORGE_RAW)

#: C9 recency window (SPEC-P4 §3, cron-hardening): only pipelines whose terminal time is
#: within this many hours are flagged, so a paging cron reports NEW error-status-0-steps
#: events, not the whole fleet's archive of already-resolved historical incidents.
C9_WINDOW_HOURS = 24.0

#: Bounded parallelism for the Woodpecker per-repo sweeps (config-fetch / repo-secrets /
#: recent-pipelines). Woodpecker is our own unmetered server, so concurrent reads are
#: fine; ~50 sequential fetches (≈220s) drop to seconds. Cap it so we never fan out an
#: unbounded thread-per-repo storm at the server.
DEFAULT_CONFIG_WORKERS = 10
_SEVERITY_ORDER = {"high": 0, "medium": 1, "low": 2}
_SSH_KINDS = frozenset({"ssh-deploy-key", "ssh-keypair"})
#: Kinds whose local files are legitimately multi-line (not the doc-form leak class):
#: SSH key material, and openstack clouds.yaml / openrc (structured YAML config).
_MULTILINE_KINDS = _SSH_KINDS | frozenset({"openstack-appcred"})
_LEAK_SUFFIXES = (".bak",)
_LEAK_PREFIXES = ("old_", "old-")


@dataclass(frozen=True)
class Finding:
    id: str
    check: str
    severity: str
    finding: str
    evidence: dict[str, Any] = field(default_factory=dict)

    def to_json(self) -> dict[str, Any]:
        return {
            "id": self.id,
            "check": self.check,
            "severity": self.severity,
            "finding": self.finding,
            "evidence": self.evidence,
        }


@dataclass(frozen=True)
class Unverifiable:
    """A check that could not be PERFORMED (SPEC §4) — distinct from both ``clean``
    and a real :class:`Finding`. Names the target it could not check and why. A cron
    must be able to tell "clean" from "couldn't look"; exit code is 1, like a finding.
    """

    check: str
    target: str
    reason: str

    def to_json(self) -> dict[str, Any]:
        return {"check": self.check, "target": self.target, "reason": self.reason}


@dataclass
class DoctorReport:
    surfaces: list[str]
    findings: list[Finding]
    skipped: list[str] = field(default_factory=list)
    unverifiable: list[Unverifiable] = field(default_factory=list)
    managed: ManagedResolution | None = None

    def exit_code(self) -> int:
        # "Couldn't look" is loud: unverifiable reds CI/the cron exactly like a finding.
        return 1 if (self.findings or self.unverifiable) else 0

    def sorted_findings(self) -> list[Finding]:
        return sorted(
            self.findings, key=lambda f: (_SEVERITY_ORDER.get(f.severity, 9), f.check, f.id)
        )

    def sorted_unverifiable(self) -> list[Unverifiable]:
        return sorted(self.unverifiable, key=lambda u: (u.check, u.target))

    def to_json(self) -> dict[str, Any]:
        out: dict[str, Any] = {
            "surfaces": self.surfaces,
            "clean": not self.findings and not self.unverifiable,
            "findings": [f.to_json() for f in self.sorted_findings()],
            "unverifiable": [u.to_json() for u in self.sorted_unverifiable()],
            "skipped": self.skipped,
        }
        if self.managed is not None:
            out["managed"] = self.managed.to_json()
        return out


class Doctor:
    """Runs the P1 checks against injected clients. No client → its checks skip."""

    def __init__(
        self,
        registry: Registry,
        *,
        fed_root: Path,
        woodpecker: WoodpeckerAPI | None = None,
        forge: ForgeAPI | None = None,
        hosts: HostAPI | None = None,
        config_source: str = CONFIG_SOURCE_LOCAL,
        pipeline_scan_limit: int = 10,
        error_window_hours: float = C9_WINDOW_HOURS,
        config_workers: int = DEFAULT_CONFIG_WORKERS,
    ) -> None:
        self.reg = registry
        self.fed_root = Path(fed_root)
        self.wp = woodpecker
        self.forge = forge
        self.hosts = hosts
        self.config_source = config_source
        self.pipeline_scan_limit = pipeline_scan_limit
        self.error_window_hours = error_window_hours
        self.config_workers = config_workers
        self._org_secrets: dict[str, frozenset[str]] | None = None
        self._repos: list[Any] | None = None
        self._repo_secrets: dict[int, dict[str, frozenset[str]]] = {}
        self._pipelines: dict[str, str] | None = None
        self._recent: dict[int, list[Any]] = {}
        self._recent_unavail: dict[int, str] = {}
        self._managed: ManagedResolution | None = None
        self._prefetched = False
        self.skipped: list[str] = []
        self.unverifiable: list[Unverifiable] = []

    # -- cached live data ---------------------------------------------------- #

    def _org_secret_scopes(self) -> dict[str, frozenset[str]]:
        if self._org_secrets is None:
            self._org_secrets = self.wp.org_secrets() if self.wp else {}
        return self._org_secrets

    def _activated(self) -> list[Any]:
        if self._repos is None:
            self._repos = self.wp.activated_repos() if self.wp else []
        return self._repos

    def _repo_secret_scopes(self, repo_id: int) -> dict[str, frozenset[str]]:
        if repo_id not in self._repo_secrets:
            self._repo_secrets[repo_id] = self.wp.repo_secrets(repo_id) if self.wp else {}
        return self._repo_secrets[repo_id]

    def _pipeline_texts(self) -> dict[str, str]:
        """repo name -> COMMITTED ``.woodpecker.yml`` text (the consumed-check source).

        ``config_source=local`` reads the committed file under the federation root (the
        drift-check pattern, the workstation default). ``forge-raw`` fetches each repo's
        RAW committed file from the forge contents API — the no-checkout mode for agent2
        (SPEC-P4 §1). Both see the SAME full committed pipeline (all events + every
        ``from_secret``). A repo whose file cannot be READ (429/5xx) is ``unverifiable``
        (loud, exit 1), never silently dropped; a repo with no committed file (404) is
        not-consumed (``skipped``), never a finding.
        """
        if self._pipelines is None:
            self._pipelines = (
                self._pipeline_texts_forge()
                if self.config_source == CONFIG_SOURCE_FORGE_RAW
                else self._pipeline_texts_local()
            )
        return self._pipelines

    def _pipeline_texts_local(self) -> dict[str, str]:
        out: dict[str, str] = {}
        for repo in self._activated():
            wp = self.fed_root / repo.name / ".woodpecker.yml"
            if wp.is_file():
                out[repo.name] = wp.read_text(encoding="utf-8")
            else:
                self.skipped.append(f"{repo.name}: no local .woodpecker.yml")
        return out

    def _parallel(self, fn: Callable[[Any], Any], items: Sequence[Any]) -> list[Any]:
        """Map ``fn`` over ``items`` with bounded parallelism (SPEC-P4 cron-perf).

        Woodpecker is our own unmetered server, so per-repo reads run concurrently
        (``config_workers`` at a time). Order is preserved and every worker returns a
        RESULT (never mutates shared state), so findings/unverifiable are appended in
        the main thread — no locks needed. Falls back to sequential for ≤1 item.
        """
        seq = list(items)
        workers = min(self.config_workers, len(seq))
        if workers <= 1:
            return [fn(it) for it in seq]
        with ThreadPoolExecutor(max_workers=workers) as ex:
            return list(ex.map(fn, seq))

    def _pipeline_texts_forge(self) -> dict[str, str]:
        """No-checkout consumed-check source: each activated repo's RAW COMMITTED
        ``.woodpecker.yml`` from the forge contents API (SPEC-P4 §1, ``forge-raw``).

        Codeberg-budgeted (§7b: cache + budget + 429→unverifiable) yet fetched with
        BOUNDED PARALLELISM (cron-perf) — a repo that 429s/5xx's is ``unverifiable``
        (never silently 0); a repo with no committed file (404 → None) is not-consumed.
        """
        out: dict[str, str] = {}
        if self.forge is None:
            # forge-raw needs the forge client (the CLI builds it); without it the
            # consumed-check cannot be performed for any repo — loud, not silently clean.
            for repo in self._activated():
                self.unverifiable.append(
                    Unverifiable("C2", f"repo-config:{repo.name}", "forge client unavailable")
                )
            return out
        forge = self.forge

        def fetch(repo: Any) -> tuple[str, str | None, str | None]:
            try:
                return repo.name, forge.repo_file(repo.name, ".woodpecker.yml"), None
            except SurfaceUnavailableError as exc:
                return repo.name, None, exc.reason

        for name, text, reason in self._parallel(fetch, self._activated()):
            if reason is not None:
                self.unverifiable.append(Unverifiable("C2", f"repo-config:{name}", reason))
            elif text:
                out[name] = text
            else:
                self.skipped.append(f"{name}: no committed .woodpecker.yml on the forge")
        return out

    # -- declared indexes ---------------------------------------------------- #

    def _wp_name(self, entry: Entry, surface: Any) -> str:
        return str(surface.get("name", entry.id))

    def _declared_org(self) -> dict[str, tuple[Entry, frozenset[str]]]:
        out: dict[str, tuple[Entry, frozenset[str]]] = {}
        for e in self.reg.with_surface("woodpecker-org-secret"):
            for s in e.surfaces_of("woodpecker-org-secret"):
                out[self._wp_name(e, s)] = (e, s.events)
        return out

    def _declared_repo(self) -> dict[tuple[str, str], tuple[Entry, frozenset[str]]]:
        out: dict[tuple[str, str], tuple[Entry, frozenset[str]]] = {}
        for e in self.reg.with_surface("woodpecker-repo-secret"):
            for s in e.surfaces_of("woodpecker-repo-secret"):
                out[(str(s["repo"]), self._wp_name(e, s))] = (e, s.events)
        return out

    def _held_ids(self) -> set[str]:
        return {e.id for e in self.reg if e.state == "held"}

    # -- concurrent prefetch (SPEC-P4 cron-perf) ----------------------------- #

    def _prefetch_woodpecker(self) -> None:
        """Warm the per-repo Woodpecker caches CONCURRENTLY: the config (C2), repo
        secrets (C1/C3/C4), and recent pipelines (C9). ~50 sequential fetches (≈220s)
        become a few seconds; the checks then read warm caches sequentially.

        Semantics are UNCHANGED: an unreadable config / pipeline-list is
        ``unverifiable`` (never silently dropped); a repo-secrets auth failure still
        propagates (fatal, as in the lazy path) — captured in the worker and re-raised
        on the main thread so the exit code is identical to the sequential run.
        """
        if self.wp is None or self._prefetched:
            return
        self._prefetched = True
        # Config-fetch is parallel inside _pipeline_texts(); warm it first.
        self._pipeline_texts()
        wp = self.wp

        def fetch(repo: Any) -> tuple[Any, Any, Any]:
            secrets: Any
            try:
                secrets = wp.repo_secrets(repo.id)
            except SurfaceUnavailableError as exc:
                secrets = ("__unverifiable__", exc.reason)
            except CredentialError as exc:  # preserve fail-hard (exit 2) for auth errors
                secrets = ("__raise__", exc)
            pipes: Any
            try:
                pipes = wp.recent_pipelines(repo.id, self.pipeline_scan_limit)
            except SurfaceUnavailableError as exc:
                pipes = ("__unverifiable__", exc.reason)
            return repo, secrets, pipes

        for repo, secrets, pipes in self._parallel(fetch, self._activated()):
            if isinstance(secrets, tuple) and secrets and secrets[0] == "__raise__":
                raise secrets[1]
            if isinstance(secrets, tuple) and secrets and secrets[0] == "__unverifiable__":
                self.unverifiable.append(
                    Unverifiable("C3", f"repo-secrets:{repo.name}", str(secrets[1]))
                )
            else:
                self._repo_secrets[repo.id] = secrets
            if isinstance(pipes, tuple) and pipes and pipes[0] == "__unverifiable__":
                self._recent_unavail[repo.id] = str(pipes[1])
                self.unverifiable.append(
                    Unverifiable("C9", f"pipelines:{repo.name}", str(pipes[1]))
                )
            else:
                self._recent[repo.id] = pipes

    def _recent_pipelines_for(self, repo: Any) -> list[Any] | None:
        """A repo's recent pipelines from the warm cache, or a lazy fetch on miss. An
        unreadable list records ONE ``C9`` unverifiable (never re-recorded)."""
        if repo.id in self._recent:
            return self._recent[repo.id]
        if repo.id in self._recent_unavail:
            return None
        try:
            pipes = self.wp.recent_pipelines(repo.id, self.pipeline_scan_limit) if self.wp else []
        except SurfaceUnavailableError as exc:
            self._recent_unavail[repo.id] = exc.reason
            self.unverifiable.append(Unverifiable("C9", f"pipelines:{repo.name}", exc.reason))
            return None
        self._recent[repo.id] = pipes
        return pipes

    # -- runner -------------------------------------------------------------- #

    def run(self, surfaces: Sequence[str] | None = None) -> DoctorReport:
        want = set(surfaces or DEFAULT_SURFACES)
        findings: list[Finding] = []
        if "woodpecker" in want and self.wp is not None:
            # Warm the per-repo caches (config / repo-secrets / recent-pipelines)
            # CONCURRENTLY before the checks read them (SPEC-P4 cron-perf).
            self._prefetch_woodpecker()
            findings += self.c1_declared_actual()
            findings += self.c2_consumed_declared()
            findings += self.c3_unregistered_secrets()
            findings += self.c4_held_discipline()
            findings += self.c8_policy()
            findings += self.c9_error_status_empty()
        if "forge" in want and self.forge is not None:
            findings += self.c5_forge_coverage()
            findings += self.c3_unregistered_deploy_keys()
        if "local" in want:
            findings += self.c6_local_hygiene()
        if "hosts" in want and self.hosts is not None:
            findings += self.c7_hosts()
        return DoctorReport(
            surfaces=sorted(want),
            findings=findings,
            skipped=self.skipped,
            unverifiable=self.unverifiable,
            managed=self._managed,
        )

    # -- C1 ------------------------------------------------------------------ #

    def c1_declared_actual(self) -> list[Finding]:
        out: list[Finding] = []
        org_live = self._org_secret_scopes()
        for name, (entry, declared) in self._declared_org().items():
            if entry.state != "active":
                continue
            if name not in org_live:
                out.append(
                    Finding(
                        entry.id,
                        "C1",
                        "high",
                        f"declared org secret {name!r} is MISSING from Woodpecker",
                        {"name": name, "declared_events": sorted(declared)},
                    )
                )
            elif org_live[name] != declared:
                out.append(
                    Finding(
                        entry.id,
                        "C1",
                        "high",
                        f"org secret {name!r} live scope != declared",
                        {
                            "name": name,
                            "declared_events": sorted(declared),
                            "live_events": sorted(org_live[name]),
                        },
                    )
                )
        repo_ids = {r.name: r.id for r in self._activated()}
        for (repo, name), (entry, declared) in self._declared_repo().items():
            if entry.state != "active" or repo not in repo_ids:
                continue
            live = self._repo_secret_scopes(repo_ids[repo])
            if name not in live:
                out.append(
                    Finding(
                        entry.id,
                        "C1",
                        "high",
                        f"declared repo secret {name!r} MISSING on {repo}",
                        {"name": name, "repo": repo, "declared_events": sorted(declared)},
                    )
                )
            elif live[name] != declared:
                out.append(
                    Finding(
                        entry.id,
                        "C1",
                        "high",
                        f"repo secret {name!r} on {repo} live scope != declared",
                        {
                            "name": name,
                            "repo": repo,
                            "declared_events": sorted(declared),
                            "live_events": sorted(live[name]),
                        },
                    )
                )
        return out

    # -- C2 ------------------------------------------------------------------ #

    def c2_consumed_declared(self) -> list[Finding]:
        out: list[Finding] = []
        org_live = self._org_secret_scopes()
        repo_ids = {r.name: r.id for r in self._activated()}
        held = self._held_ids()
        for repo, text in self._pipeline_texts().items():
            _, refs = parse_pipeline(text)
            # Held-entry references are the C4 domain (the held inversion); C2
            # reproduces the live-scope audit for everything else (CIR-02 parity).
            refs = [r for r in refs if r.secret not in held]
            allowed = dict(org_live)
            if repo in repo_ids:
                allowed.update(self._repo_secret_scopes(repo_ids[repo]))
            for sf in audit_refs(refs, allowed):
                sev = "high"
                fid = sf.secret
                ev: dict[str, Any]
                if sf.kind == "undefined":
                    msg = (
                        f"pipeline in {repo} references {sf.secret!r} on {sf.referenced} but it "
                        "is defined at NEITHER org nor repo level (injects empty)"
                    )
                    ev = {"repo": repo, "referenced": sf.referenced, "kind": "undefined"}
                else:
                    msg = (
                        f"pipeline in {repo} references {sf.secret!r} on {sorted(sf.bad_events)} "
                        f"but its live scope is only {sf.allowed}"
                    )
                    ev = {
                        "repo": repo,
                        "referenced": sf.referenced,
                        "kind": "scope",
                        "live_events": sf.allowed,
                        "bad_events": sorted(sf.bad_events),
                    }
                out.append(Finding(fid, "C2", sev, msg, ev))
        return out

    # -- C3 ------------------------------------------------------------------ #

    def c3_unregistered_secrets(self) -> list[Finding]:
        out: list[Finding] = []
        declared_org = set(self._declared_org())
        for name in sorted(self._org_secret_scopes()):
            if name not in declared_org:
                out.append(
                    Finding(
                        f"unregistered:{name}",
                        "C3",
                        "high",
                        f"live ORG secret {name!r} is absent from the registry (ungoverned)",
                        {"name": name, "scope": "org"},
                    )
                )
        declared_repo = self._declared_repo()
        for repo in self._activated():
            live = self._repo_secret_scopes(repo.id)
            for name in sorted(live):
                if (repo.name, name) not in declared_repo:
                    out.append(
                        Finding(
                            f"unregistered:{repo.name}:{name}",
                            "C3",
                            "high",
                            f"live REPO secret {name!r} on {repo.name} is absent from the "
                            "registry (ungoverned)",
                            {"name": name, "repo": repo.name, "scope": "repo"},
                        )
                    )
        return out

    def _forge_keys(self, repo: str, check: str) -> list[Any] | None:
        """Read a repo's deploy keys, converting a rate-limit/unreachable into an
        ``unverifiable`` record (SPEC §4) instead of a silent empty list or a crash.
        Returns ``None`` when the repo could not be read."""
        try:
            return list(self.forge.repo_deploy_keys(repo)) if self.forge else []
        except SurfaceUnavailableError as exc:
            self.unverifiable.append(Unverifiable(check, f"forge-deploy-key:{repo}", exc.reason))
            return None

    def _declared_key_identity(self) -> tuple[set[str], set[str]]:
        """(declared titles, declared fingerprints) across every ``forge-deploy-key``
        surface. The registry may declare only a title today, so fingerprints are
        usually derived from the live fleet (see the callers) — this seeds the set
        with any explicitly-declared ``fingerprint``."""
        titles: set[str] = set()
        fps: set[str] = set()
        for e in self.reg.with_surface("forge-deploy-key"):
            for s in e.surfaces_of("forge-deploy-key"):
                titles.add(str(s["title"]))
                fp = s.get("fingerprint")
                if fp:
                    fps.add(str(fp))
        return titles, fps

    def c3_unregistered_deploy_keys(self) -> list[Finding]:
        out: list[Finding] = []
        if self.forge is None:
            return out
        declared_titles, declared_fps = self._declared_key_identity()
        repos = self._sampled_forge_repos()
        # Read each repo's keys ONCE (cached in the forge client; one unverifiable
        # record per unreadable repo, not one per pass).
        repo_keys = {repo: self._forge_keys(repo, "C3") for repo in repos}
        # A deploy key's identity is its FINGERPRINT, not its title (SPEC-P4 §7c;
        # aligns with the 0.3.0 sync fix). Reference fingerprints = the declared ones
        # PLUS every fingerprint observed under a declared TITLE across the fleet — so a
        # repo carrying the fleet key under a *different* title is recognised as
        # governed, never paged as a steward-class rogue.
        ref_fps = set(declared_fps)
        for keys in repo_keys.values():
            if keys is None:
                continue
            for k in keys:
                if k.title in declared_titles and k.fingerprint:
                    ref_fps.add(k.fingerprint)
        for repo in repos:
            keys = repo_keys[repo]
            if keys is None:
                continue
            for key in keys:
                if key.title in declared_titles or (key.fingerprint and key.fingerprint in ref_fps):
                    continue
                out.append(
                    Finding(
                        f"unregistered-key:{repo}:{key.title}",
                        "C3",
                        "high",
                        f"undeclared deploy key {key.title!r} on {repo} (steward-class)",
                        {
                            "repo": repo,
                            "title": key.title,
                            "fingerprint": key.fingerprint,
                            "read_only": key.read_only,
                        },
                    )
                )
        return out

    def _managed_resolution(self) -> ManagedResolution:
        """``ALL_MANAGED`` resolved ONCE per run (SPEC §4): computed on first use,
        cached, and stable. The coverage sweep iterates exactly this set.

        SPEC-P4 §7a — the union is **invocation-independent**: it is the same
        MGF_LIBRARIES + SOOTH_LIBRARIES + Woodpecker-activated set no matter which
        surfaces were selected. If the activated source cannot be reached (no
        woodpecker client, or a live read failure) the union is a lower bound, so an
        ``ALL_MANAGED`` ``unverifiable`` is recorded — the cron pages "couldn't fully
        verify coverage" rather than silently under-reporting because of how it was run.
        """
        if self._managed is None:
            activated, reachable, reason = self._activated_for_managed()
            self._managed = resolve_all_managed_detail(
                fed_root=self.fed_root,
                activated_repos=activated,
                activated_reachable=reachable,
            )
            if not reachable:
                self.unverifiable.append(
                    Unverifiable("ALL_MANAGED", "woodpecker-activated", reason)
                )
            self.skipped.append(
                f"ALL_MANAGED resolved: {self._managed.count} repos "
                f"(sources: {self._managed.sources}; activated_reachable={reachable})"
            )
        return self._managed

    def _activated_for_managed(self) -> tuple[list[str], bool, str]:
        """The Woodpecker-activated repo names for ``ALL_MANAGED`` (SPEC-P4 §7a).

        Never lets "how the doctor was invoked" shrink the fleet: when the woodpecker
        surface was not selected the client may still be injected purely to complete
        the union. When no client is available (or a live read fails) the source is
        UNREACHABLE → coverage degrades to ``unverifiable``, never a silent shrink to
        the committed libraries alone.
        """
        if self.wp is None:
            return [], False, "woodpecker client unavailable — activated set not read"
        try:
            return [r.name for r in self._activated()], True, ""
        except (CredentialError, SurfaceUnavailableError) as exc:  # pragma: no cover - live-only
            return [], False, f"activated set unreadable ({exc})"

    def _sampled_forge_repos(self) -> list[str]:
        return sorted(self._managed_resolution().repos)

    # -- C4 ------------------------------------------------------------------ #

    def c4_held_discipline(self) -> list[Finding]:
        out: list[Finding] = []
        org_live = self._org_secret_scopes()
        repo_ids = {r.name: r.id for r in self._activated()}
        # (a) a held entry must not exist as a live Woodpecker secret.
        for name, (entry, _) in self._declared_org().items():
            if entry.state == "held" and name in org_live:
                out.append(
                    Finding(
                        entry.id,
                        "C4",
                        "high",
                        f"HELD entry {name!r} is ARMED as a live org secret — the founder's "
                        "hold is in force; un-hold deliberately, never by arming",
                        {"name": name, "scope": "org", "live_events": sorted(org_live[name])},
                    )
                )
        for (repo, name), (entry, _) in self._declared_repo().items():
            if entry.state == "held" and repo in repo_ids:
                live = self._repo_secret_scopes(repo_ids[repo])
                if name in live:
                    out.append(
                        Finding(
                            entry.id,
                            "C4",
                            "high",
                            f"HELD entry {name!r} is ARMED as a live repo secret on {repo}",
                            {"name": name, "repo": repo, "live_events": sorted(live[name])},
                        )
                    )
        # (b) pipelines referencing held (or undefined) secrets — the CIR-33 shape.
        held = self._held_ids()
        for repo, text in self._pipeline_texts().items():
            _, refs = parse_pipeline(text)
            seen: set[str] = set()
            for r in refs:
                if r.secret in held and r.secret not in seen:
                    seen.add(r.secret)
                    out.append(
                        Finding(
                            r.secret,
                            "C4",
                            "medium",
                            f"pipeline in {repo} references HELD secret {r.secret!r} "
                            "(deploy path held — reported, not fixed)",
                            {"repo": repo, "referenced": sorted(r.events), "state": "held"},
                        )
                    )
        return out

    # -- C5 ------------------------------------------------------------------ #

    def c5_forge_coverage(self) -> list[Finding]:
        out: list[Finding] = []
        if self.forge is None:
            return out
        # Coverage requirement per repo, built with the SAME repos parser sync uses
        # (mgf.secrets.managed.normalize_repos): ALL_MANAGED → the fleet; a
        # comma-separated string (the live steward key) → its individual repos; a
        # list → its members. Reading each repo's keys ONCE avoids re-reads (and a
        # duplicate unverifiable record) when several entries target the same repo.
        managed = self._managed_resolution().repos
        declared_titles, _declared_fps = self._declared_key_identity()
        reqs: dict[str, list[tuple[Entry, Any, str]]] = {}
        for e in self.reg.with_surface("forge-deploy-key"):
            if e.state != "active":
                continue
            for s in e.surfaces_of("forge-deploy-key"):
                title = str(s["title"])
                for repo in normalize_repos(s.get("repos"), managed=managed):
                    reqs.setdefault(repo, []).append((e, s, title))
        # Read each target repo's keys ONCE (cached; one unverifiable record per repo).
        repo_keys = {repo: self._forge_keys(repo, "C5") for repo in sorted(reqs)}
        # Per-title reference fingerprints (SPEC-P4 §7c): a declared ``fingerprint``
        # PLUS every fingerprint the fleet actually carries under that title — so a repo
        # carrying the key under a DIFFERENT title (same content) counts as covered and
        # the cron does not page a false coverage gap.
        ref_fp_by_title: dict[str, set[str]] = {}
        for e in self.reg.with_surface("forge-deploy-key"):
            for s in e.surfaces_of("forge-deploy-key"):
                fp = s.get("fingerprint")
                if fp:
                    ref_fp_by_title.setdefault(str(s["title"]), set()).add(str(fp))
        for keys in repo_keys.values():
            if keys is None:
                continue
            for k in keys:
                if k.title in declared_titles and k.fingerprint:
                    ref_fp_by_title.setdefault(k.title, set()).add(k.fingerprint)
        for repo in sorted(reqs):
            keys = repo_keys[repo]
            if keys is None:
                continue
            titles = {k.title for k in keys}
            fprints = {k.fingerprint for k in keys if k.fingerprint}
            for entry, surface, title in reqs[repo]:
                ref_fps = ref_fp_by_title.get(title, set())
                covered_by_title = title in titles
                covered_by_fp = bool(ref_fps & fprints)
                if not covered_by_title and not covered_by_fp:
                    out.append(
                        Finding(
                            entry.id,
                            "C5",
                            "high",
                            f"repo {repo} is MISSING declared deploy key {title!r} (coverage gap)",
                            {"repo": repo, "title": title},
                        )
                    )
                elif surface.get("fingerprint") and str(surface["fingerprint"]) not in fprints:
                    out.append(
                        Finding(
                            entry.id,
                            "C5",
                            "medium",
                            f"repo {repo} carries {title!r} with a mismatched fingerprint",
                            {
                                "repo": repo,
                                "title": title,
                                "declared_fingerprint": str(surface["fingerprint"]),
                            },
                        )
                    )
        return out

    # -- C6 ------------------------------------------------------------------ #

    def c6_local_hygiene(self) -> list[Finding]:
        out: list[Finding] = []
        repos_with_local: set[str] = set()
        for entry in self.reg.with_surface("local-file"):
            for s in entry.surfaces_of("local-file"):
                repo = str(s["repo"])
                repos_with_local.add(repo)
                rel = str(s["path"])
                declared_mode = str(s["mode"])
                full = self.fed_root / repo / "my_stuff" / rel
                if not full.exists():
                    out.append(
                        Finding(
                            entry.id,
                            "C6",
                            "medium",
                            f"declared local-file {repo}/my_stuff/{rel} is MISSING",
                            {"repo": repo, "path": rel},
                        )
                    )
                    continue
                actual = _octal(full)
                if actual and actual.lstrip("0") != declared_mode.lstrip("0"):
                    out.append(
                        Finding(
                            entry.id,
                            "C6",
                            "medium",
                            f"{repo}/my_stuff/{rel} mode {actual} != declared {declared_mode}",
                            {
                                "repo": repo,
                                "path": rel,
                                "actual_mode": actual,
                                "declared_mode": declared_mode,
                            },
                        )
                    )
                # doc-form: a bare-value file that is actually multi-line.
                if entry.kind not in _MULTILINE_KINDS and not s.get("multiline_ok"):
                    nlines = _nonblank_lines(full)
                    if nlines > 1:
                        out.append(
                            Finding(
                                entry.id,
                                "C6",
                                "medium",
                                f"{repo}/my_stuff/{rel} looks like a DOC-FORM file "
                                f"({nlines} non-blank lines; bare-value expected) — the "
                                "cat-a-doc leak class",
                                {"repo": repo, "path": rel, "nonblank_lines": nlines},
                            )
                        )
                # JWT exp decode (in-process; only the integer leaves).
                if entry.kind == "jwt-minted":
                    out += self._jwt_expiry(entry, full, f"{repo}/my_stuff/{rel}")
        # my_stuff dir hygiene + gitignore + leftover scan, per referenced repo.
        for repo in sorted(repos_with_local):
            out += self._my_stuff_hygiene(repo)
        return out

    def _jwt_expiry(self, entry: Entry, path: Path, label: str) -> list[Finding]:
        exp = _jwt_exp(path)
        if exp is None:
            return []
        now = time.time()
        if exp < now:
            return [
                Finding(
                    entry.id,
                    "C6",
                    "high",
                    f"{label}: JWT is EXPIRED",
                    {"path": label, "exp": int(exp), "expired": True},
                )
            ]
        if exp - now <= 7 * 86400:
            return [
                Finding(
                    entry.id,
                    "C6",
                    "medium",
                    f"{label}: JWT expires in ≤7 days",
                    {"path": label, "exp": int(exp), "days_left": int((exp - now) // 86400)},
                )
            ]
        return []

    def _my_stuff_hygiene(self, repo: str) -> list[Finding]:
        out: list[Finding] = []
        base = self.fed_root / repo / "my_stuff"
        if not base.is_dir():
            return out
        top_mode = _octal(base)
        if top_mode and top_mode.lstrip("0") != "700":
            out.append(
                Finding(
                    f"hygiene:{repo}",
                    "C6",
                    "medium",
                    f"{repo}/my_stuff is mode {top_mode}, want 0700",
                    {"repo": repo, "actual_mode": top_mode},
                )
            )
        gi = self.fed_root / repo / ".gitignore"
        if not gi.is_file() or "my_stuff" not in gi.read_text(encoding="utf-8"):
            out.append(
                Finding(
                    f"hygiene:{repo}",
                    "C6",
                    "high",
                    f"{repo}/.gitignore does NOT cover my_stuff/",
                    {"repo": repo},
                )
            )
        for p in sorted(base.rglob("*")):
            if not p.is_file():
                continue
            n = p.name
            if n.endswith(_LEAK_SUFFIXES) or n.startswith(_LEAK_PREFIXES):
                rel = p.relative_to(self.fed_root / repo)
                out.append(
                    Finding(
                        f"leftover:{repo}:{rel}",
                        "C6",
                        "low",
                        f"credential leftover {rel} in {repo}/my_stuff (.bak/old_* — prune)",
                        {"repo": repo, "path": str(rel)},
                    )
                )
        return out

    # -- C7 ------------------------------------------------------------------ #

    def c7_hosts(self) -> list[Finding]:
        out: list[Finding] = []
        if self.hosts is None:
            return out
        for entry in self.reg.with_surface("host-file"):
            for s in entry.surfaces_of("host-file"):
                host, path = str(s["host"]), str(s["path"])
                st = self.hosts.stat(host, path)
                if st.status == "missing":
                    out.append(
                        Finding(
                            entry.id,
                            "C7",
                            "high",
                            f"declared host-file {host}:{path} is MISSING",
                            {"host": host, "path": path},
                        )
                    )
                    continue
                if st.status == "unreachable":
                    # Present-but-unverifiable — NEVER page as MISSING (a present file
                    # is not an absent one). But "couldn't look" is now LOUD: an
                    # unverifiable record (exit 1), so a cron tells it from clean (§4).
                    self.unverifiable.append(
                        Unverifiable("C7", f"{host}:{path}", st.detail or "unreachable")
                    )
                    continue
                want_mode, want_owner = str(s["mode"]), str(s["owner"])
                if st.mode and st.mode.lstrip("0") != want_mode.lstrip("0"):
                    out.append(
                        Finding(
                            entry.id,
                            "C7",
                            "medium",
                            f"{host}:{path} mode {st.mode} != declared {want_mode}",
                            {
                                "host": host,
                                "path": path,
                                "actual_mode": st.mode,
                                "declared_mode": want_mode,
                            },
                        )
                    )
                if st.owner != want_owner:
                    out.append(
                        Finding(
                            entry.id,
                            "C7",
                            "medium",
                            f"{host}:{path} owner {st.owner} != declared {want_owner}",
                            {
                                "host": host,
                                "path": path,
                                "actual_owner": st.owner,
                                "declared_owner": want_owner,
                            },
                        )
                    )
        return out

    # -- C8 ------------------------------------------------------------------ #

    def c8_policy(self) -> list[Finding]:
        out: list[Finding] = []
        for repo, text in self._pipeline_texts().items():
            _, refs = parse_pipeline(text)
            for r in {ref.secret for ref in refs}:
                entry = self.reg.get(r)
                if entry is None:
                    continue
                allowed = entry.policy.allowed_repos
                if allowed == "*" or allowed is None:
                    continue
                allow_list = allowed if isinstance(allowed, list) else [allowed]
                if repo not in allow_list:
                    out.append(
                        Finding(
                            entry.id,
                            "C8",
                            "medium",
                            f"secret {r!r} referenced in {repo}, outside allowed_repos "
                            f"{allow_list}",
                            {"repo": repo, "allowed_repos": allow_list},
                        )
                    )
        return out

    # -- C9 (error-status-0-steps watch, SPEC-P4 §3) ------------------------- #

    def c9_error_status_empty(self) -> list[Finding]:
        """Recent pipelines in a terminal ERROR status with ZERO executed steps — the
        "nothing gates, nothing pages" shape a compile-time empty-secret injection
        produces (CIR-02 post-failure net). Reports repo + pipeline + as-of.

        **Recency-bounded** (SPEC-P4 cron-hardening): only pipelines whose terminal time
        is within ``error_window_hours`` are flagged, so a paging cron reports NEW
        events — not the whole fleet's archive of already-resolved historical incidents
        (which would re-file every run and train the operator to ignore the pager). The
        static audit (C1/C2) owns the root cause continuously; C9 is the recent-events
        net. A repo whose pipeline list cannot be READ is ``unverifiable``, never clean.
        """
        out: list[Finding] = []
        if self.wp is None:
            return out
        now = time.time()
        window = self.error_window_hours * 3600
        for repo in self._activated():
            pipelines = self._recent_pipelines_for(repo)
            if pipelines is None:
                continue
            for p in pipelines:
                if p.status not in ERROR_STATUSES or p.step_count != 0:
                    continue
                as_of = p.finished or p.created
                # Dated history is not the pager's job (a known/resolved incident); a
                # missing timestamp is flagged defensively (never silently dropped).
                if as_of > 0 and (now - as_of) > window:
                    continue
                out.append(
                    Finding(
                        f"error-status:{repo.name}:{p.number}",
                        "C9",
                        "high",
                        f"pipeline #{p.number} on {repo.name} terminally ERRORED with 0 "
                        f"executed steps within {self.error_window_hours:g}h (the CIR-02 "
                        "empty-injection / silent-empty shape)",
                        {
                            "repo": repo.name,
                            "pipeline": p.number,
                            "status": p.status,
                            "as_of": as_of,
                            "within_hours": self.error_window_hours,
                        },
                    )
                )
        return out
