"""``mgf-fed feedback`` — relay cross-repo feedback outboxes into target inboxes (FB-05).

A sync session is read-only on every other federation repo, so a finding destined elsewhere is
drafted into the source repo's ``pending-feedback/<target>.md`` *outbox*. This command is the other
half: the **supervisor** drains those outboxes into each target's ``FEEDBACK.md`` *inbox*, tracked +
idempotent. See ``mgf-standard/docs/recipes/feedback-relay.md``.

- ``mgf-fed feedback`` (default / ``--check``): scan every member's outbox, list un-relayed papers,
  exit non-zero if any exist — the no-silent-rot gate.
- ``mgf-fed feedback --check --in-repo [PATH]``: scan THIS repo's ``pending-feedback/`` outbox
  regardless of registry membership — so a consumer app (not a registry member) can gate its OWN
  CI on un-relayed feedback it drafted. Closes CIR-16 blind spot (a): the fleet scan only sees
  registry members, so a consumer-app outbox was invisible.
- ``--fresh`` (any check mode): read the outbox from ``origin/main`` via git rather than the dirty
  working tree — so a stale clone missing a just-pushed paper can no longer false-green. Closes
  CIR-16 blind spot (b).
- ``mgf-fed feedback --relay [--target R] [--dry-run]``: assign the target's next ``PAPER-NN``,
  render the inbox entry, append it to the target ``FEEDBACK.md``, move the source paper to
  ``pending-feedback/relayed/``, and print the reviewed PR commands the supervisor lands.

Pure stdlib (pathlib + re + a read-only ``git show``/``ls-tree`` for ``--fresh``); the cross-repo
PRs stay a reviewed human step (printed, not auto-pushed), per the "read-only on the federation
except the supervisor" invariant.
"""

from __future__ import annotations

import re
import shutil
import subprocess
from dataclasses import dataclass
from pathlib import Path

from mgf.fed._roster import federation_root, members

#: Papers are split on their heading. The recipe
#: (``mgf-standard/docs/recipes/feedback-relay.md`` §Outbox schema) documents ``###``,
#: but two real outboxes wrote ``##`` and were **silently invisible for 24 days** — the
#: split found nothing, so the no-silent-rot gate reported "no un-relayed feedback ✓"
#: and exited 0. Accept both depths; anything else is caught by
#: :func:`unparseable_reason` rather than skipped.
#:
#: Up to three leading SPACES are a heading in CommonMark; four (or a tab) make a code
#: block. Both patterns below use the same indent rule, so a heading can never be a paper
#: to one of them and not the other — that disagreement is what let a red paper through.
_PAPER_SPLIT = re.compile(r"^ {0,3}#{2,3}[ \t]*(?=PAPER\b)", re.MULTILINE)

#: Any markdown heading that names a PAPER — the evidence that a file *intends* to hold
#: papers. Deliberately wider than ``_PAPER_SPLIT``: the gap between what looks like a
#: paper and what parses as one is exactly the blindness being closed. Allows CommonMark's
#: up-to-3-space indent and a missing space after the hashes, both of which a human writes
#: by accident and neither of which :data:`_PAPER_SPLIT` accepts.
_PAPER_HEADING = re.compile(
    r"""^[ ]{0,3}(?:                             # VERBOSE strips literal spaces:
          \#{1,6}[ \t]*                           # a bare `^ {0,3}` compiles to `^{0,3}`
        | \*{2,3}[ \t]*(?=[Pp][Aa][Pp][Ee][Rr])   # ...and dies with "nothing to repeat"
        )(?P<head>[Pp][Aa][Pp][Ee][Rr]\b[^\n]*)
    """,
    re.MULTILINE | re.VERBOSE,
)

#: Setext: a PAPER line underlined by === or ---. Its own pattern because the
#: evidence is on the NEXT line, which a single-line regex cannot see.
_PAPER_SETEXT = re.compile(
    r"^ {0,3}(?P<head>[Pp][Aa][Pp][Ee][Rr]\b[^\n]*)\n {0,3}(?:=+|-+)[ \t]*$",
    re.MULTILINE,
)

#: An opening code fence: 0-3 spaces, then >= 3 backticks or tildes, then an info string.
_FENCE_OPEN = re.compile(r"^(?P<ind> {0,3})(?P<mark>`{3,}|~{3,})(?P<info>[^\n]*)$", re.MULTILINE)
_SEVERITY = re.compile(r"\*\*Severity:\*\*\s*(?P<sev>[^\n]+)")
_ORIGIN = re.compile(r"\*\*(?:Origin|Filed by):\*\*\s*(?P<origin>[^\n]+)")
_DATE = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
_PAPER_NO = re.compile(r"\bPAPER-(\d+)\b")


@dataclass(frozen=True, slots=True)
class OutboxPaper:
    """One un-relayed paper sitting in a source repo's ``pending-feedback/<target>.md``."""

    source: str  # the repo that drafted it (read-only on the target)
    target: str  # the repo it is destined for (== the outbox file stem)
    title: str
    severity: str
    origin: str  # the Origin/Filed-by line (becomes the inbox Submitter + cross-ref)
    block: str  # the VERBATIM markdown block, at the heading depth the source actually used
    file: Path  # the outbox file it lives in
    # Byte offsets of `block` within the text THIS object was parsed from. The relay does not
    # use the scanned object's offsets (the file may have moved on); it re-parses the live
    # source and splices with the offsets of the paper it selects there. What kills the
    # look-alike class is that the candidate set is PARSED PAPERS ONLY — a fenced example is
    # never one — not that the offsets are threaded through from the scan.
    start: int = 0
    end: int = 0


#: Leading noise on a paper heading, stripped in order to leave the human title:
#: the ``PAPER``/``PAPER-NN`` token, then the optional ``(→ target)`` the recipe allows,
#: then the optional ``—``/``-`` separator.
_HEAD_ID = re.compile(r"^PAPER(?:-\d+)?\b")
#: ONLY the documented routing parenthetical — one containing an arrow. An earlier cut ate
#: *any* leading ``(...)``, which silently deleted a real ``(draft)`` from a title on its way
#: into the target's permanent FEEDBACK.md: the same "mangled title looks like a result"
#: failure this function exists to end, moved from the tail to the head.
_HEAD_ROUTE = re.compile(r"^\s*\(\s*(?:→|->)[^)\n]*\)")
_HEAD_SEP = re.compile("^\\s*[\u2014\u2013-]\\s*")  # em dash, en dash, hyphen


def _paper_starts(text: str) -> list[re.Match[str]]:
    """Where each paper begins — the SINGLE source of truth the parser and the consistency
    check both use, so ``parsed`` can never mean something the parser does not do."""
    return _outside_fences(text, _PAPER_SPLIT)


def _title_from_heading(head: str) -> str:
    """The human title from a paper's heading text (everything after ``PAPER-NN``).

    The previous implementation searched the whole block for ``PAPER…<sep><title>`` with a
    NON-GREEDY prefix, so a heading that carried its own em-dash — e.g. ``PAPER-88 🟡 A child
    whose stderr is *expected* has no way to say so — `run()` always warns`` — silently yielded
    the TAIL (``run() always warns``) as the title, and that mangled title would have been
    written into the target's permanent ``FEEDBACK.md``. Anchoring to the id instead means a
    heading in an unexpected shape keeps every word of its title.
    """
    rest = _HEAD_ID.sub("", head.strip(), count=1)
    rest = _HEAD_ROUTE.sub("", rest, count=1)
    rest = _HEAD_SEP.sub("", rest, count=1)
    return rest.strip() or "(untitled)"


@dataclass(frozen=True, slots=True)
class _Fence:
    """One fenced code block. ``closed`` is the fact the earlier cut computed and threw away."""

    start: int
    end: int
    closed: bool


def _fence_spans(text: str) -> list[_Fence]:
    """Every fenced code block, unterminated ones running to EOF — and whether each was CLOSED.

    A regex could not do this honestly. The earlier ``_FENCE`` pattern required the closing
    fence to be byte-identical to the opener and unindented, so three CommonMark-legal shapes
    (a longer closer, an indented fence, an unclosed fence at EOF) fell out of the span and
    their contents were read as real papers — one minting a PHANTOM paper that ``--relay``
    would have written into a permanent ``FEEDBACK.md``.

    **Carrying ``closed`` is what stops a runaway fence from being a silent green.** Both the
    parser and the consistency check filter through these spans, so a fence that never
    terminates removes its papers from *both* sides of the count — the check then agrees with
    itself about a region it has decided not to look at, and reports clean over a 🔴 paper
    still sitting in the bytes. One deleted ``` line did that to a real fleet outbox.
    """
    spans: list[_Fence] = []
    pos = 0
    while (m := _FENCE_OPEN.search(text, pos)) is not None:
        mark, info = m.group("mark"), m.group("info")
        # An info string may not contain a backtick when the fence is backticks (CommonMark).
        if mark[0] == "`" and "`" in info:
            pos = m.end()
            continue
        # `\r` is allowed before the line end: on a CRLF file the closer is "```\r", and
        # without this EVERY fence was unterminated — H-1 by a different road.
        close = re.compile(rf"^ {{0,3}}{re.escape(mark[0])}{{{len(mark)},}}[ \t\r]*$", re.MULTILINE)
        c = close.search(text, m.end())
        end = c.end() if c else len(text)  # unterminated fence swallows the rest of the file
        spans.append(_Fence(m.start(), end, closed=c is not None))
        pos = end
    return spans


def _outside_fences(text: str, pattern: re.Pattern[str]) -> list[re.Match[str]]:
    """Matches of ``pattern`` that do not begin inside a fenced code block."""
    spans = _fence_spans(text)
    return [
        m for m in pattern.finditer(text) if not any(f.start <= m.start() < f.end for f in spans)
    ]


def _runaway_fence_reason(text: str) -> str | None:
    """An UNTERMINATED fence that has swallowed PAPER headings — or None.

    Checked BEFORE the count comparison, because a runaway fence hides the papers from both
    counters at once and the comparison would report agreement.
    """
    for f in _fence_spans(text):
        if f.closed:
            continue
        buried = _PAPER_HEADING.findall(text[f.start : f.end])
        if buried:
            line = text.count("\n", 0, f.start) + 1
            return (
                f"unterminated code fence opened at line {line} — {len(buried)} PAPER "
                f"heading(s) below it are inside the fence and cannot be read. If a closing "
                f"fence is missing, one deleted line has hidden real papers"
            )
    return None


def unparseable_reason(text: str) -> str | None:
    """Why this outbox body holds papers the parser cannot read — or None if it is consistent.

    The FB-05 gate's whole promise is "no silent rot". Before this, a body whose headings the
    splitter did not recognise produced an empty list, byte-identical to an empty outbox — so
    ``feedback --check`` printed "No un-relayed feedback in any outbox ✓" and exited **0** over
    five undelivered papers, one batch for 24 days.

    **The bar is EVERY heading, not at least one.** A first cut returned None as soon as any
    heading split cleanly, which left the worst version of the bug reachable: in a mixed-depth
    file the unparsed blocks are absorbed into the previous paper's body, so ``--relay`` deletes
    them from the source and buries them inside another paper in the target — and the gate then
    reports clean.

    **And the two numbers must be counted the same way.** The second cut compared a
    ``_PAPER_SPLIT.split`` chunk count (which counts the *prefix* chunk when a file happens to
    begin with the word PAPER, though that chunk yields no paper) against a heading count taken
    over a *different*, fence-stripped view of the text. Two wrong counters agreed, and a 🔴
    paper was reported clean at exit 0. ``parsed`` is now literally the papers the parser
    produces, and both counts run over one view — every paper start is also a heading match, so
    ``parsed <= headings`` always and the message cannot contradict itself.
    """
    if (runaway := _runaway_fence_reason(text)) is not None:
        return runaway
    starts = _paper_starts(text)
    # Setext headings are counted alongside ATX/bold ones: the point of this
    # comparison is "does the file INTEND to hold more papers than parsed", and a
    # shape the detector cannot see is a shape the gate silently forgives.
    headings = _outside_fences(text, _PAPER_HEADING) + _outside_fences(text, _PAPER_SETEXT)
    if len(starts) == len(headings):
        return None  # consistent — including the honest, common case of zero papers
    parsed_at = {m.start() for m in starts}
    missed = [m.group("head") for m in headings if m.start() not in parsed_at]
    shown = "; ".join(h[:60] for h in missed[:3]) or "(depth/spacing differs)"
    return (
        f"{len(headings)} PAPER heading(s) present but only {len(starts)} parsed — the recipe's "
        f"outbox schema is `### PAPER (→ <target>) — <title>` (feedback-relay.md §Outbox "
        f"schema); unreadable: {shown}"
    )


def _parse_outbox(text: str, source: str, target: str, file: Path) -> list[OutboxPaper]:
    """Split a ``pending-feedback/<target>.md`` body into its ``### PAPER …`` blocks.

    ``block`` is kept **verbatim** — at whatever heading depth the source actually used. An
    earlier cut normalised it to ``###``, which made :func:`_relay_one`'s
    ``src_text.replace(paper.block, "")`` a silent no-op for every ``##`` outbox: the paper was
    appended to the target, the source was never drained, the tool printed success, and a second
    run duplicated it. The heading is stripped at render time instead, where depth cannot matter.
    """
    out: list[OutboxPaper] = []
    offsets = [m.start() for m in _paper_starts(text)]
    for i, start in enumerate(offsets):
        end = offsets[i + 1] if i + 1 < len(offsets) else len(text)
        # The RAW slice, not `.rstrip() + "\n"`. That normalisation reintroduced F-2 on any
        # CRLF outbox: it turned the block's trailing `\r\n` into `\n`, so `block in text` was
        # False and `_relay_one`'s `replace()` silently did nothing again. Slicing guarantees
        # removability by construction instead of by a rule someone has to remember.
        block = text[start:end]
        head_m = _PAPER_HEADING.search(block)
        sev_m = _SEVERITY.search(block)
        origin_m = _ORIGIN.search(block)
        out.append(
            OutboxPaper(
                source=source,
                target=target,
                title=(_title_from_heading(head_m.group("head")) if head_m else "(untitled)"),
                severity=(sev_m.group("sev").strip() if sev_m else ""),
                origin=(origin_m.group("origin").strip() if origin_m else source),
                block=block,
                file=file,
                start=start,
                end=end,
            )
        )
    return out


#: Fallback spellings, tried in order, only when ``origin/HEAD`` is not set in the
#: clone. ``origin/HEAD`` is the forge's own answer and is asked first.
_FRESH_REF_FALLBACKS = ("origin/main", "origin/master")


def _git_available() -> bool:
    """True when a ``git`` binary is on PATH. The canonical CI image (``python:*-slim``) ships
    WITHOUT git — the whole drift path avoids it for this reason — so every git shell-out here is
    gated on this first; ``--fresh`` degrades to the working tree when it is False (never crashes,
    never false-greens)."""
    return shutil.which("git") is not None


def _git_lines(repo_dir: Path, *args: str) -> list[str] | None:
    """Run a read-only ``git`` command in ``repo_dir``; return stdout lines, or None on failure
    (git missing, not a repo, ref/path absent, non-zero exit) so callers can fall back cleanly."""
    if not _git_available():
        return None
    try:
        proc = subprocess.run(  # noqa: S603 — fixed argv, no shell
            ["git", "-C", str(repo_dir), *args],  # noqa: S607 — git resolved from PATH by design
            capture_output=True,
            text=True,
            timeout=30,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    if proc.returncode != 0:
        return None
    return proc.stdout.splitlines()


def _fresh_ref(repo_dir: Path) -> str | None:
    """The remote-tracking ref for ``repo_dir``'s DEFAULT branch, or None.

    This used to be the constant ``origin/main``. That is wrong wherever the
    default branch is not called ``main`` — figrecovery and the four ``sooth*``
    repos default to ``master`` — and it fails in the most expensive available
    direction: ``--fresh`` silently falls back to the WORKING TREE and reports
    the reason *"origin/main does not resolve"*, which is true and irrelevant
    while a perfectly good ``origin/master`` sits beside it. A reduced scope
    announced for the wrong reason is not much better than a silent one.

    ``origin/HEAD`` is asked first because it is the forge's own answer; the
    named fallbacks exist only for clones where nobody ever set it.
    """
    head = _git_lines(repo_dir, "symbolic-ref", "--quiet", "refs/remotes/origin/HEAD")
    if head and head[0].startswith("refs/remotes/origin/"):
        return "origin/" + head[0].rsplit("/", 1)[-1]
    for cand in _FRESH_REF_FALLBACKS:
        if _git_lines(repo_dir, "rev-parse", "--verify", "--quiet", cand) is not None:
            return cand
    return None


def _fresh_supported(repo_dir: Path) -> bool:
    """True when ``--fresh`` can honestly read the default branch for ``repo_dir`` — i.e. git is
    on PATH AND that branch's remote-tracking ref resolves. When False, ``--fresh`` MUST fall back
    to the working tree rather than report an empty outbox (which would false-green, the opposite
    of the intent)."""
    return _fresh_ref(repo_dir) is not None


def _fresh_unavailable_reason(repo_dir: Path) -> str:
    """A short human reason ``--fresh`` fell back for ``repo_dir`` (for the CLI note).

    Names every spelling that was tried, so the note says what was actually
    looked for rather than asserting one hardcoded name at the reader.
    """
    if not _git_available():
        return "git not on PATH"
    return (
        "no default-branch remote-tracking ref resolves (tried origin/HEAD, then "
        + ", ".join(_FRESH_REF_FALLBACKS)
        + ") — reduced to the working tree"
    )


def _fresh_outbox_files(repo_dir: Path) -> list[str] | None:
    """The top-level ``pending-feedback/*.md`` filenames on ``origin/main`` (skips ``relayed/``),
    read via git so a dirty/stale working tree can't hide or invent a paper. None if the tree/dir
    is unavailable."""
    ref = _fresh_ref(repo_dir)
    if ref is None:
        return None
    lines = _git_lines(repo_dir, "ls-tree", "--name-only", f"{ref}:pending-feedback")
    if lines is None:
        return None
    return [name for name in lines if name.endswith(".md")]  # blobs only → relayed/ tree dropped


@dataclass(frozen=True, slots=True)
class OutboxBody:
    """One outbox file as the walk saw it: its text, or why it could not be read.

    ``text is None`` means the file exists and could NOT be read. That case used to raise
    (aborting the whole fleet walk) or, worse, be smoothed over with ``errors="replace"`` —
    which turned a loud ``UnicodeDecodeError`` on a UTF-16 outbox into ``exit 0``.
    """

    target: str
    file: Path
    text: str | None
    error: str | None = None


def _read_outbox(file: Path) -> OutboxBody:
    """Read one outbox file, converting every failure into a REPORTABLE body rather than an
    exception. ``_relay_one`` already catches ``OSError`` per paper "so one bad target never
    aborts the batch"; the scan owed the fleet the same courtesy — before this, a single
    chmod-000 outbox aborted the walk, leaving every other repo neither verified nor reported.
    """
    try:
        raw = file.read_bytes()
    except OSError as exc:
        return OutboxBody(file.stem, file, None, f"{type(exc).__name__}: {exc}")
    try:
        return OutboxBody(file.stem, file, raw.decode("utf-8"))
    except UnicodeDecodeError as exc:
        return OutboxBody(
            file.stem,
            file,
            None,
            f"not valid UTF-8 ({exc.reason} at byte {exc.start}) — the papers inside are "
            f"unreadable, which is NOT the same as an empty outbox",
        )


def _working_tree_bodies(repo_dir: Path) -> list[OutboxBody]:
    """Each top-level ``pending-feedback/*.md`` on disk, read or explained."""
    outbox = repo_dir / "pending-feedback"
    if not outbox.is_dir():
        return []
    return [_read_outbox(f) for f in sorted(outbox.glob("*.md"))]  # skips relayed/ subdir


def outbox_bodies(repo_dir: Path, *, fresh: bool = False) -> list[OutboxBody]:
    """Every outbox body in ONE repo — the single walk that :func:`scan_repo_all` consumes for
    BOTH papers and problems, so the two can never disagree about what was looked at (and the
    ``--fresh`` git shell-outs are paid for once, not twice)."""
    if fresh and (fresh_ref := _fresh_ref(repo_dir)) is not None:
        bodies: list[OutboxBody] = []
        for name in sorted(_fresh_outbox_files(repo_dir) or []):
            file = repo_dir / "pending-feedback" / name  # the on-disk path, for the xref/report
            lines = _git_lines(repo_dir, "show", f"{fresh_ref}:pending-feedback/{name}")
            if lines is None:
                bodies.append(
                    OutboxBody(
                        Path(name).stem,
                        file,
                        None,
                        f"listed in {fresh_ref}:pending-feedback but `git show` failed for it",
                    )
                )
                continue
            bodies.append(OutboxBody(Path(name).stem, file, "\n".join(lines)))
        return bodies
    return _working_tree_bodies(repo_dir)


def scan_repo(repo_dir: Path, repo: str, *, fresh: bool = False) -> list[OutboxPaper]:
    """Every un-relayed outbox paper in ONE repo's ``pending-feedback/`` — regardless of registry
    membership (CIR-16 (a): a consumer app is not a member but still drafts outbox papers).

    ``fresh=True`` reads the outbox from the repo's DEFAULT-branch remote-tracking ref via git
    (CIR-16 (b): a stale clone whose working tree is missing a just-pushed paper no longer
    false-greens). The ref is resolved per repo by :func:`_fresh_ref`, never hardcoded to
    ``origin/main`` — a repo on ``master`` would otherwise degrade on every single run while
    reporting that ``origin/main`` does not resolve. When freshness can't be honored (no git
    binary — the slim CI image — or no default-branch ref at all), it **degrades to the working
    tree** rather than reporting an empty outbox; the CLI prints a note naming what was tried.
    """
    return scan_repo_all(repo_dir, repo, fresh=fresh)[0]


@dataclass(frozen=True, slots=True)
class OutboxProblem:
    """An outbox the gate could not fully read — unreadable bytes, or papers it cannot parse.

    This is the state that had no representation at all: it used to be reported as an empty
    outbox, i.e. as success. It is now its own kind, so it can reach an exit code.
    """

    source: str
    target: str
    file: Path
    reason: str


def scan_repo_all(
    repo_dir: Path, repo: str, *, fresh: bool = False
) -> tuple[list[OutboxPaper], list[OutboxProblem]]:
    """ONE walk of ONE repo's outbox, returning both the papers and the bodies it could not
    read. Callers get a matched pair by construction — there is no second walk to disagree."""
    papers: list[OutboxPaper] = []
    problems: list[OutboxProblem] = []
    for body in outbox_bodies(repo_dir, fresh=fresh):
        if body.text is None:
            problems.append(OutboxProblem(repo, body.target, body.file, body.error or "unreadable"))
            continue
        papers.extend(_parse_outbox(body.text, repo, body.target, body.file))
        if (reason := unparseable_reason(body.text)) is not None:
            problems.append(OutboxProblem(repo, body.target, body.file, reason))
    return papers, problems


def scan_repo_problems(repo_dir: Path, repo: str, *, fresh: bool = False) -> list[OutboxProblem]:
    """Outbox bodies in ONE repo the gate could not fully read."""
    return scan_repo_all(repo_dir, repo, fresh=fresh)[1]


def scan(root: Path | None = None, *, fresh: bool = False) -> list[OutboxPaper]:
    """Every un-relayed outbox paper across the fleet — registry members AND any other
    cloned repo that keeps an outbox (FEEDBACK PAPER-123).

    The member loop alone made ``--check`` structurally blind to the consumer-app tier:
    TelePlasma, vm-vmanager-app, figrecovery and hrb-app all file feedback but are not in
    ``MGF_LIBRARIES.json``, so a 🔴 paper could sit in one of their outboxes forever while
    the fleet check printed "No un-relayed feedback in any outbox ✓". A false green over a
    no-silent-rot guarantee is worse than no guarantee.

    Non-member sources are discovered the same way ``_drift.ungoverned_repos`` finds
    ungoverned repos — scan the federation root for real checkouts that carry the
    convention — so this is zero-config and needs no registry edit to cover a new app.

    ``fresh=True`` reads each outbox from ``origin/main`` via git rather than its working
    tree (CIR-16 (b)).
    """
    return scan_all(root, fresh=fresh)[0]


def scan_all(
    root: Path | None = None, *, fresh: bool = False
) -> tuple[list[OutboxPaper], list[OutboxProblem]]:
    """ONE fleet walk returning both the papers and the outboxes that could not be read."""
    papers: list[OutboxPaper] = []
    problems: list[OutboxProblem] = []
    for repo_dir, name in _outbox_repos(root):
        p, pr = scan_repo_all(repo_dir, name, fresh=fresh)
        papers.extend(p)
        problems.extend(pr)
    return papers, problems


def _outbox_repos(root: Path | None = None) -> list[tuple[Path, str]]:
    """Every repo the FB-05 scan covers: registry members, plus any other real checkout that
    keeps a ``pending-feedback/`` directory (the consumer-app tier, FEEDBACK PAPER-123)."""
    base = root or federation_root()
    out: list[tuple[Path, str]] = []
    seen: set[str] = set()
    for member in members(base):
        seen.add(member.repo)
        out.append((member.repo_dir, member.repo))
    for repo_dir in sorted(base.iterdir()):
        name = repo_dir.name
        if name in seen or not repo_dir.is_dir():
            continue
        # A real checkout that keeps an outbox — not a scratch dir or a build output.
        if not (repo_dir / ".git").exists() or not (repo_dir / "pending-feedback").is_dir():
            continue
        out.append((repo_dir, name))
    return out


def scan_problems(root: Path | None = None, *, fresh: bool = False) -> list[OutboxProblem]:
    """Every outbox across the fleet the gate could not fully read."""
    return scan_all(root, fresh=fresh)[1]


def _next_paper_number(feedback_text: str) -> int:
    """The target's next local ``PAPER-NN`` — max existing + 1 (FB-01: never reuse/renumber)."""
    nums = [int(n) for n in _PAPER_NO.findall(feedback_text)]
    return (max(nums) + 1) if nums else 1


def _render_inbox_entry(paper: OutboxPaper, number: int) -> str:
    """Render the FB-02 inbox entry for the target's ``FEEDBACK.md`` (+ the FB-01 cross-ref)."""
    date_m = _DATE.search(paper.origin)
    submitted = date_m.group(1) if date_m else "(see Origin)"
    sev = paper.severity or "—"
    xref = f"`{paper.source}/pending-feedback/{paper.target}.md`"
    # Strip the outbox heading line; keep the body bullets verbatim. Matched by the same
    # pattern the parser uses, so a heading shape the parser accepted can never survive here.
    body = _PAPER_HEADING.sub("", paper.block, count=1).strip()
    return (
        f"### PAPER-{number:02d} — {paper.title}\n\n"
        f"| Field | Value |\n|---|---|\n"
        f"| Status | OPEN |\n"
        f"| Submitter | {paper.origin} |\n"
        f"| Submitted | {submitted} |\n"
        f"| Severity | {sev} |\n"
        f"| Federation paper | relayed by the supervisor from {xref} (FB-05) |\n\n"
        f"{body}\n"
    )


def _seed_feedback_md(repo: str) -> str:
    """A minimal canonical ``FEEDBACK.md`` header (the feedback-md-template shape).

    Written when a relay targets a member that has no inbox yet (e.g. a freshly
    onboarded repo) — the first relay to a new member must seed, not crash
    (PAPER-120). The shape follows
    ``mgf-standard/docs/recipes/feedback-md-template.md``.
    """
    return (
        f"# FEEDBACK — {repo}\n\n"
        f"> Paper-tracking file for issues, friction, and requests against `{repo}`\n"
        "> (DOC-01 / DOC-13). Consumer projects (and any future adopter) file\n"
        f"> PAPER-NN entries here. **{repo}-specific** → file HERE; **federation-wide**\n"
        "> (cross-sibling patterns, standards proposals) → file in\n"
        "> [mgf-common/FEEDBACK.md](https://codeberg.org/magogi-admin/mgf-common/src/branch/main/FEEDBACK.md).\n"
        ">\n"
        "> **Numbering:** paper numbers (`PAPER-NN`) are local to this repo (FB-01).\n"
        "> **Lifecycle (FB-01..04):** `OPEN` → `ACCEPTED` / `SCHEDULED` / `DEFERRED` /\n"
        "> `❌ REJECTED` → `✅ SHIPPED`.\n\n"
        "---\n"
    )


def _pr_commands(repo: str, branch: str, paths: str, message: str) -> str:
    return (
        f"    cd {repo} && git checkout -b {branch} && git add {paths} \\\n"
        f"      && git commit -m {message!r} && git push -u origin {branch} \\\n"
        f"      && tea pr create --login codeberg --repo magogi-admin/{repo} "
        f"--base main --head {branch} --title {message!r}"
    )


def _atomic_write(path: Path, text: str) -> None:
    """Write ``text`` to ``path`` without ever leaving it truncated.

    ``Path.write_text`` opens with ``"w"``, which truncates FIRST — so a mid-write ``ENOSPC``
    left an outbox at zero bytes, and the inbox rollback that followed then removed the last
    surviving copy of every paper in it. Writing a sibling temp file and ``os.replace``-ing it
    means a failure leaves the original bytes exactly as they were, which is also what makes the
    rollback above safe to perform at all.
    """
    tmp = path.with_name(path.name + ".mgf-fed-tmp")
    try:
        tmp.write_text(text, encoding="utf-8")
        if path.exists():  # K-2: a fresh temp file carries the umask, not the target's mode
            tmp.chmod(path.stat().st_mode & 0o7777)
        tmp.replace(path)  # atomic rename within the same directory
    except OSError:
        tmp.unlink(missing_ok=True)
        raise


def _relay_one(paper: OutboxPaper, number: int, root: Path, *, dry_run: bool) -> str:
    """Apply (or preview) one relay: append the target inbox entry + move the source to relayed/."""
    target_repo = root / paper.target
    feedback = target_repo / "FEEDBACK.md"
    entry = _render_inbox_entry(paper, number)
    head = f"{paper.source} → {paper.target}#PAPER-{number:02d}: {paper.title}"
    if dry_run:
        return f"  {head}\n" + "\n".join(f"      | {ln}" for ln in entry.splitlines())

    # Locate the paper by OFFSET among the papers a fresh parse finds, and commit in an order
    # where any single failure leaves nothing behind.
    #
    # Five defects lived in the two lines this replaces, all of them the same shape — the
    # relay looking at a different view of the file than the scan did:
    #
    #   * `read_text()` applies universal newlines, so a CRLF outbox's `\r\n` became `\n` while
    #     `paper.block` (sliced from `read_bytes().decode()`) kept it. `replace()` matched
    #     nothing, said "moved source paper", and a second run wrote DUPLICATES into a permanent
    #     FEEDBACK.md — then the gate reported clean, so the corruption self-healed into green.
    #   * `.rstrip()` deleted the file's trailing blank lines, but the LAST paper's block
    #     *contains* them (its slice runs to EOF) — so relaying paper 1 made paper 2 unmatchable.
    #   * the target was appended BEFORE the source was drained, so any failure of either left a
    #     permanent entry with the paper still sitting in its outbox.
    #   * `replace(block, "", 1)` could delete the WRONG occurrence: a fenced *example* byte-
    #     identical to the paper sits earlier in the file, so the example was destroyed and the
    #     paper left behind — then the next run appended it a second time. Verifying that
    #     "something was removed" is not verifying that the right thing was.
    #   * `Path.write_text` TRUNCATES before writing, so a mid-write ENOSPC emptied the outbox;
    #     the inbox rollback then removed the last surviving copy. Total loss, then a green gate.
    #
    # So: locate by OFFSET among the papers a FRESH PARSE finds — a fenced example is never a
    # parsed paper, which kills the look-alike class outright — and write every file atomically,
    # so a failed write leaves the previous bytes intact rather than a truncated file.
    src_text = paper.file.read_bytes().decode("utf-8")  # byte-identical to how the scan read it
    live = [
        p
        for p in _parse_outbox(src_text, paper.source, paper.target, paper.file)
        if p.block == paper.block
    ]
    if not live:
        raise OSError(
            f"{paper.file}: this paper is no longer among the papers parsed from its source, so "
            f"it cannot be removed — refusing to append it to {paper.target}/FEEDBACK.md and "
            f"claim it was relayed (the file changed since the scan read it)"
        )
    hit = live[0]  # the FIRST matching paper: identical twins relay one at a time
    drained = src_text[: hit.start] + src_text[hit.end :]

    # Commit order: every step is undone by the one that follows it failing, so no single
    # failure can leave the estate disagreeing with itself about whether this paper was relayed.
    relayed_dir = paper.file.parent / "relayed"
    stamp_file = relayed_dir / f"{paper.target}-PAPER-{number:02d}.md"
    stamp = f"> **Relayed** to {paper.target}#PAPER-{number:02d} by the supervisor (FB-05).\n\n"
    relayed_dir.mkdir(exist_ok=True)
    # K-1: NEVER overwrite an existing FB-05 record. `number` comes from the TARGET's inbox, so
    # a reset or hand-edited FEEDBACK.md can hand back a number this source has already used —
    # and the record of who filed what, and where it landed, is the one thing here with no
    # second copy.
    if stamp_file.exists():
        raise OSError(
            f"{stamp_file} already exists — {paper.target}#PAPER-{number:02d} has a relayed "
            f"record from an earlier relay. Refusing to overwrite an FB-05 record; the target's "
            f"paper numbering has been reused (check {paper.target}/FEEDBACK.md)"
        )
    _atomic_write(stamp_file, stamp + paper.block)

    # PAPER-120: a freshly-onboarded member has no FEEDBACK.md yet — seed the
    # canonical header instead of crashing the whole relay batch.
    seeded = not feedback.is_file()
    existing = _seed_feedback_md(paper.target) if seeded else feedback.read_bytes().decode("utf-8")
    try:
        _atomic_write(feedback, existing.rstrip() + "\n\n" + entry)
    except OSError:
        # Only ever the stamp THIS call wrote — the refusal above guarantees the path did
        # not exist when we got here, so no earlier record can be reached from this line.
        stamp_file.unlink(missing_ok=True)
        raise
    try:
        _atomic_write(paper.file, drained)  # no rstrip: the tail belongs to the last paper
    except OSError:
        # Never leave the target holding a paper its source still has. Safe to roll back only
        # because the source write is atomic: a failure means the outbox still has every byte.
        # Only ever the stamp THIS call wrote — the refusal above guarantees the path did
        # not exist when we got here, so no earlier record can be reached from this line.
        stamp_file.unlink(missing_ok=True)
        if seeded:
            feedback.unlink(missing_ok=True)
        else:
            _atomic_write(feedback, existing)
        raise
    return (
        f"  ✓ {head}\n"
        + (
            f"    + seeded {paper.target}/FEEDBACK.md (no inbox yet — PAPER-120)\n"
            if seeded
            else ""
        )
        + f"    + appended to {paper.target}/FEEDBACK.md\n"
        f"    + moved source paper → {paper.source}/pending-feedback/relayed/\n"
        f"    review the diff, then land both PRs (codeberg-pr.md):\n"
        + _pr_commands(
            paper.target,
            f"docs/feedback-paper-{number:02d}",
            "FEEDBACK.md",
            f"docs: relay PAPER-{number:02d} from {paper.source} (FB-05)",
        )
        + "\n"
        + _pr_commands(
            paper.source,
            "docs/feedback-relayed",
            "pending-feedback",
            f"docs: mark {paper.target}#PAPER-{number:02d} relayed (FB-05)",
        )
    )


def run_feedback(
    *,
    relay: bool = False,
    target: str | None = None,
    dry_run: bool = False,
    root: Path | None = None,
    in_repo: str | None = None,
    fresh: bool = False,
) -> int:
    """``mgf-fed feedback`` — scan the outboxes (gate) or relay them into target inboxes.

    ``in_repo`` (a path, ``.`` = cwd) switches to single-repo mode: scan THAT repo's outbox
    regardless of registry membership — the consumer-app gate (CIR-16 (a)). ``fresh`` reads the
    outbox from ``origin/main`` via git rather than the working tree (CIR-16 (b)).
    """
    base = root or federation_root()
    if in_repo is not None:
        if relay:
            # Relay is a supervisor action off the federation root (it needs every target repo on
            # disk); the in-repo mode is the check-only consumer gate. Keep them from crossing.
            print("feedback: --in-repo is a check-only mode; run --relay from the federation root.")
            return 2
        repo_dir = Path(in_repo).expanduser().resolve()
        if fresh and not _fresh_supported(repo_dir):
            print(
                f"note: --fresh unavailable ({_fresh_unavailable_reason(repo_dir)}); "
                "reading the working tree instead."
            )
        papers, problems = scan_repo_all(repo_dir, repo_dir.name, fresh=fresh)
    else:
        if fresh and not _git_available():
            print("note: --fresh unavailable (git not on PATH); reading working trees instead.")
        papers, problems = scan_all(base, fresh=fresh)
    if target:
        papers = [p for p in papers if p.target == target]
        problems = [p for p in problems if p.target == target]

    if relay and problems:
        # NEVER WRITE while any outbox in scope is unreadable. Relay DELETES the paper from its
        # source, so relaying past a body we could not parse is how a paper gets destroyed rather
        # than merely delayed — the unparsed blocks are absorbed into the previous paper and go
        # with it. --dry-run writes nothing, so it is still allowed to run: refusing it too would
        # take away the only tool for INSPECTING the outbox you are being told to fix.
        verb = "would refuse to relay" if dry_run else "REFUSING to relay"
        print(f"{verb} — {len(problems)} outbox(es) in scope cannot be read:")
        for pr in problems:
            print(f"  {pr.source} → {pr.target}: {pr.reason}")
            print(f"    {pr.file}")
        print("\nRelay removes each paper from its source, so a body that is only partly")
        print("readable would lose the part that is not. Fix the outbox, then relay.")
        if not dry_run:
            return 1
        print("\n--dry-run writes nothing; the plan below is what a FIXED outbox would do.\n")

    if not relay:
        # An outbox that could not be READ is reported before, and separately from, an outbox
        # that is empty — "could not verify" must never share an exit code with "verified".
        if problems:
            print(f"Unreadable outbox ({len(problems)} file(s) — FB-05 cannot verify these):")
            for pr in problems:
                print(f"  {pr.source} → {pr.target}: {pr.reason}")
                print(f"    {pr.file}")
            if papers:
                print()
        if not papers and not problems:
            print("No un-relayed feedback in any outbox ✓")
            return 0
        if papers:
            print(f"Un-relayed feedback ({len(papers)} paper(s) — FB-05 debt):")
            for p in papers:
                print(f"  {p.source} → {p.target}: {p.title}  {p.severity}".rstrip())
            print("\nRelay with `mgf-fed feedback --relay` (preview: add --dry-run).")
        if problems:
            # Exit 2, NOT 1. Ordinary debt ("there is feedback to relay") and a
            # scan that could not read an outbox ("I do not know how much there
            # is") were both exit 1, so a CI failure could not tell an operator
            # which one had happened — and the second is strictly worse, because
            # the reported debt is then a floor, not a total.
            print(
                f"\n{len(problems)} outbox(es) COULD NOT BE READ — this scan is incomplete, "
                "so any debt reported above is a floor and not a total."
            )
            return 2
        return 1  # the gate: un-relayed feedback is a non-zero condition

    if not papers:
        print("Nothing to relay ✓" + (f" (target {target})" if target else ""))
        return 0
    # Assign target-local PAPER-NN sequentially, accounting for multiple papers per target.
    counters: dict[str, int] = {}
    failures: list[str] = []
    print("Relay plan (dry-run — no files written):" if dry_run else "Relaying:")
    for p in papers:
        if p.target not in counters:
            fb = base / p.target / "FEEDBACK.md"
            counters[p.target] = _next_paper_number(
                fb.read_text(encoding="utf-8") if fb.is_file() else ""
            )
        number = counters[p.target]
        counters[p.target] += 1
        # PAPER-120: capture per-paper errors so one bad target never aborts the
        # batch mid-way (each paper's write+move stays atomic; the rest continue).
        try:
            print(_relay_one(p, number, base, dry_run=dry_run))
        except OSError as exc:
            failures.append(f"{p.source} → {p.target}: {p.title} — {type(exc).__name__}: {exc}")
            print(f"  ✗ {p.source} → {p.target}: {p.title}\n    ERROR {exc} (paper left in outbox)")
            # Force a re-read of the target's inbox for any later paper — the
            # failed attempt may or may not have consumed the number.
            counters.pop(p.target, None)
    if not dry_run:
        print(
            "\nEdits applied locally + source papers moved to relayed/. "
            "Review + run the PR commands above."
        )
    if failures:
        print(f"\n{len(failures)} relay(s) FAILED (papers left in their outboxes):")
        for f in failures:
            print(f"  ✗ {f}")
        return 1
    return 0
