"""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, replace
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 .acknowledgements import Acknowledgements
    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)
    #: Set when a committed acknowledgement matches this finding (SPEC-P4 ack governor).
    #: An acknowledged finding is STILL reported (never silently dropped) — the paging
    #: cron just does not page on it. ``ack_reason`` carries the human justification.
    acknowledged: bool = False
    ack_reason: str | None = None

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


@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.
        # Acknowledged findings STILL count as findings here (the doctor is honest about
        # drift); the paging decision (pageable-only) is the watch layer's job.
        return 1 if (self.findings or self.unverifiable) else 0

    def pageable_findings(self) -> list[Finding]:
        """Findings a paging cron acts on — everything NOT acknowledged (SPEC-P4 ack
        governor). An acknowledged finding is reported but never paged."""
        return [f for f in self.findings if not f.acknowledged]

    def acknowledged_findings(self) -> list[Finding]:
        return [f for f in self.findings if f.acknowledged]

    def pageable(self) -> bool:
        """Whether this run should page: any UN-acknowledged finding, or any
        ``unverifiable`` (couldn't-look is never suppressible by an ack)."""
        return bool(self.pageable_findings()) or bool(self.unverifiable)

    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,
        acknowledgements: Acknowledgements | None = None,
        store_root: Path | None = None,
    ) -> None:
        self.reg = registry
        self.fed_root = Path(fed_root)
        #: The credential store checkout — C10 reads its git state. Defaults to the
        #: sibling layout so existing callers keep working unchanged.
        self.store_root = Path(store_root) if store_root else self.fed_root / "mgf-secrets-store"
        self.wp = woodpecker
        self.forge = forge
        self.hosts = hosts
        self.acks = acknowledgements
        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()
            findings += self.c10_store_committed()
        if "hosts" in want and self.hosts is not None:
            findings += self.c7_hosts()
        findings = self._apply_acks(findings)
        return DoctorReport(
            surfaces=sorted(want),
            findings=findings,
            skipped=self.skipped,
            unverifiable=self.unverifiable,
            managed=self._managed,
        )

    def _apply_acks(self, findings: list[Finding]) -> list[Finding]:
        """Flag findings a committed acknowledgement matches (SPEC-P4 ack governor).

        An acknowledged finding is REPORTED (returned, flagged ``acknowledged=True``)
        — never dropped — so ``--json`` and the table still show it; the paging cron is
        what skips it. An EXPIRED matching ack does NOT suppress (the finding pages
        again) and is recorded as an audit note so the operator sees the ack lapsed.
        """
        if self.acks is None or not self.acks:
            return findings
        now = time.time()
        out: list[Finding] = []
        for f in findings:
            repo = f.evidence.get("repo") if isinstance(f.evidence, dict) else None
            ack = self.acks.active_match(f.check, f.id, repo, now=now)
            if ack is not None:
                out.append(replace(f, acknowledged=True, ack_reason=ack.reason))
                continue
            expired = self.acks.expired_match(f.check, f.id, repo, now=now)
            if expired is not None:
                self.skipped.append(
                    f"ack for {f.check}:{f.id} EXPIRED ({expired.expires}) — finding no "
                    "longer suppressed (pages again)"
                )
            out.append(f)
        return out

    # -- 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 c10_store_committed(self) -> list[Finding]:
        """Every stored value must be COMMITTED to the store repo (FEEDBACK PAPER-03).

        `add`/`set` write ciphertext into the working tree; before this check nothing
        noticed when the commit never happened, and the store's git log — which IS the
        rotation/audit record — silently lacked the value. An uncommitted value is also
        one `git clean` from unrecoverable, so this is durability, not just tidiness.
        """
        from . import store as store_mod

        dirty = store_mod.uncommitted(self.store_root)
        if not dirty:
            return []
        ids = sorted({Path(p).stem for p in dirty if p.endswith(".age")})
        return [
            Finding(
                ids[0] if len(ids) == 1 else "store",
                "C10",
                "high",
                f"{len(dirty)} uncommitted path(s) in the credential store "
                f"({', '.join(ids) if ids else 'manifest only'}) — the value exists on disk "
                "but not in git: no audit trail, and a clean checkout would lose it",
                {"paths": dirty, "ids": ids, "store": str(self.store_root)},
            )
        ]

    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
