"""Unit tests for ``mgf-fed feedback`` (FB-05 outbox relay)."""

from __future__ import annotations

import os
import shutil
import subprocess
import sys
from pathlib import Path

import pytest

from mgf.fed import _feedback
from mgf.fed._feedback import (
    _next_paper_number,
    _parse_outbox,
    _render_inbox_entry,
    run_feedback,
    scan,
    scan_repo,
    unparseable_reason,
)
from mgf.fed._roster import Member

OUTBOX = """# pending-feedback → mgf-common

> **Outbox.** Drafted here, destined for mgf-common.

## Pending

### PAPER (→ mgf-common) — doctor should report N/A on non-Python members
- **Severity:** 🟢 Nice-to-have
- **Filed by:** mgf-standard (conformance sweep) on 2026-06-07
- **What hurts:** `doctor --standards` exits non-zero on the Zig members.
- **Suggested shape:** detect non-Python shape → emit N/A, exit 0.
"""


def test_parse_outbox_extracts_fields() -> None:
    papers = _parse_outbox(OUTBOX, "mgf-standard", "mgf-common", Path("mgf-common.md"))
    assert len(papers) == 1
    p = papers[0]
    # exact: the title must NOT bleed in the "(→ mgf-common)" prefix (the spaced-dash separator)
    assert p.title == "doctor should report N/A on non-Python members"
    assert "🟢" in p.severity
    assert "mgf-standard" in p.origin and "2026-06-07" in p.origin
    assert p.source == "mgf-standard" and p.target == "mgf-common"


def test_next_paper_number() -> None:
    assert _next_paper_number("### PAPER-01\nfoo\n### PAPER-07 — x\n") == 8
    assert _next_paper_number("no papers yet") == 1


def test_render_inbox_entry_is_fb02_with_xref() -> None:
    p = _parse_outbox(OUTBOX, "mgf-standard", "mgf-common", Path("mgf-common.md"))[0]
    entry = _render_inbox_entry(p, 12)
    assert "### PAPER-12 —" in entry
    assert "| Status | OPEN |" in entry
    assert "| Submitted | 2026-06-07 |" in entry  # date lifted from the Origin line
    assert "pending-feedback/mgf-common.md" in entry  # FB-01 cross-reference
    assert "What hurts" in entry  # body preserved verbatim


def _fake_fleet(tmp_path: Path, monkeypatch) -> None:
    (tmp_path / "mgf-standard" / "pending-feedback").mkdir(parents=True)
    (tmp_path / "mgf-standard" / "pending-feedback" / "mgf-common.md").write_text(OUTBOX, "utf-8")
    (tmp_path / "mgf-common").mkdir()
    (tmp_path / "mgf-common" / "FEEDBACK.md").write_text(
        "# Feedback\n\n### PAPER-03 — old\n", "utf-8"
    )
    fleet = [
        Member(repo="mgf-standard", lang="python", registry_version="0", root=tmp_path),
        Member(repo="mgf-common", lang="python", registry_version="0", root=tmp_path),
    ]
    monkeypatch.setattr(_feedback, "members", lambda base: fleet)


def test_scan_finds_unrelayed(tmp_path: Path, monkeypatch) -> None:
    _fake_fleet(tmp_path, monkeypatch)
    papers = scan(tmp_path)
    assert len(papers) == 1 and papers[0].target == "mgf-common"


def test_check_gate_then_relay_then_idempotent(tmp_path: Path, monkeypatch) -> None:
    _fake_fleet(tmp_path, monkeypatch)
    # the gate: un-relayed feedback → non-zero
    assert run_feedback(root=tmp_path) == 1
    # dry-run writes nothing
    assert run_feedback(relay=True, dry_run=True, root=tmp_path) == 0
    assert not (tmp_path / "mgf-standard" / "pending-feedback" / "relayed").exists()
    # relay applies: next number after PAPER-03 is PAPER-04
    assert run_feedback(relay=True, root=tmp_path) == 0
    fb = (tmp_path / "mgf-common" / "FEEDBACK.md").read_text(encoding="utf-8")
    assert "### PAPER-04 —" in fb
    relayed = tmp_path / "mgf-standard" / "pending-feedback" / "relayed" / "mgf-common-PAPER-04.md"
    assert relayed.is_file()
    # idempotent: the source paper moved to relayed/ → re-scan is clean (gate passes)
    assert run_feedback(root=tmp_path) == 0


def test_relay_seeds_missing_target_feedback_md(tmp_path: Path, monkeypatch) -> None:
    """PAPER-120: relaying to a member with no FEEDBACK.md seeds the canonical header."""
    _fake_fleet(tmp_path, monkeypatch)
    (tmp_path / "mgf-common" / "FEEDBACK.md").unlink()  # newly-onboarded member: no inbox yet
    assert run_feedback(relay=True, root=tmp_path) == 0
    fb_path = tmp_path / "mgf-common" / "FEEDBACK.md"
    assert fb_path.is_file()
    fb = fb_path.read_text(encoding="utf-8")
    assert fb.startswith("# FEEDBACK — mgf-common")  # seeded canonical header
    assert "### PAPER-01 —" in fb  # first paper in a fresh inbox is PAPER-01
    relayed = tmp_path / "mgf-standard" / "pending-feedback" / "relayed" / "mgf-common-PAPER-01.md"
    assert relayed.is_file()


def test_relay_continues_past_a_failing_target(tmp_path: Path, monkeypatch) -> None:
    """PAPER-120: a per-paper error is captured — the batch continues, exit is non-zero,
    and the failed paper stays in its outbox."""
    _fake_fleet(tmp_path, monkeypatch)
    # Second outbox paper destined for a target whose repo dir does not exist at
    # all → the inbox write raises (FileNotFoundError: parent dir missing).
    (tmp_path / "mgf-standard" / "pending-feedback" / "mgf-ghost.md").write_text(
        OUTBOX.replace("mgf-common", "mgf-ghost"), "utf-8"
    )
    assert run_feedback(relay=True, root=tmp_path) == 1
    # the healthy target was still relayed (batch not aborted)
    fb = (tmp_path / "mgf-common" / "FEEDBACK.md").read_text(encoding="utf-8")
    assert "### PAPER-04 —" in fb
    # the failing paper is untouched in its outbox — nothing half-applied
    ghost_outbox = tmp_path / "mgf-standard" / "pending-feedback" / "mgf-ghost.md"
    assert "### PAPER" in ghost_outbox.read_text(encoding="utf-8")
    assert not (
        tmp_path / "mgf-standard" / "pending-feedback" / "relayed" / "mgf-ghost-PAPER-01.md"
    ).exists()
    # re-run after fixing (create the repo dir): the leftover paper relays clean
    (tmp_path / "mgf-ghost").mkdir()
    assert run_feedback(relay=True, root=tmp_path) == 0
    assert (tmp_path / "mgf-ghost" / "FEEDBACK.md").is_file()


# --- CIR-16: in-repo mode + freshness (the two rot-gate blind spots) --------------------

# The canonical CI image (python:*-slim) has NO git binary; tests that build a real git repo
# skip there. The git-less DEGRADATION path is covered separately (monkeypatched) so it runs
# everywhere.
requires_git = pytest.mark.skipif(
    shutil.which("git") is None, reason="git not on PATH (e.g. the slim CI image)"
)


def _git(repo: Path, *args: str) -> None:
    # `-c commit.gpgsign=false` belts the braces of the suite-wide git isolation
    # in tests/conftest.py: a signing config reached from ANY source turns the
    # commit below into a blocking call on the developer's SSH agent, and the
    # symptom is a hang, not a failure.
    subprocess.run(  # noqa: S603 — fixed git argv over a tmp_path fixture
        ["git", "-C", str(repo), "-c", "commit.gpgsign=false", *args],  # noqa: S607
        check=True,
        capture_output=True,
        text=True,
    )


def _outbox(repo: Path, target: str, body: str = OUTBOX) -> Path:
    (repo / "pending-feedback").mkdir(parents=True, exist_ok=True)
    f = repo / "pending-feedback" / f"{target}.md"
    f.write_text(body, "utf-8")
    return f


def test_in_repo_finds_a_non_member_consumer_outbox(tmp_path: Path, monkeypatch) -> None:
    """CIR-16 (a): a consumer app (e.g. TelePlasma) is NOT a registry member, so the fleet scan
    never sees its outbox. --in-repo scans THAT repo regardless of membership."""
    consumer = tmp_path / "TelePlasma"
    _outbox(consumer, "mgf-common")
    # The fleet scan, given a root with an empty registry, sees nothing (the blind spot)…
    empty_root = tmp_path / "root"
    (empty_root / "mgf-standard").mkdir(parents=True)
    (empty_root / "mgf-standard" / "MGF_LIBRARIES.json").write_text(
        '{"release_waves": [], "libraries": []}', "utf-8"
    )
    assert scan(empty_root) == []
    # …but the in-repo scan finds TelePlasma's un-relayed paper.
    papers = scan_repo(consumer, "TelePlasma")
    assert len(papers) == 1 and papers[0].source == "TelePlasma"
    # the CLI gate fires non-zero for the consumer's own CI
    assert run_feedback(in_repo=str(consumer), root=empty_root) == 1


def test_in_repo_clean_repo_passes(tmp_path: Path) -> None:
    clean = tmp_path / "app"
    clean.mkdir()
    assert run_feedback(in_repo=str(clean)) == 0  # no outbox → gate passes


def test_in_repo_rejects_relay(tmp_path: Path, capsys) -> None:
    """Relay is a supervisor action off the federation root, never the in-repo consumer gate."""
    consumer = tmp_path / "app"
    consumer.mkdir()
    assert run_feedback(relay=True, in_repo=str(consumer)) == 2
    assert "check-only" in capsys.readouterr().out


@requires_git
def test_fresh_reads_origin_main_not_the_dirty_tree(tmp_path: Path) -> None:
    """CIR-16 (b): a stale clone whose working tree is MISSING a just-pushed paper false-greens on
    the working-tree read — but --fresh reads origin/main and still finds it."""
    repo = tmp_path / "clone"
    repo.mkdir()
    _git(repo, "init", "-b", "main")
    _git(repo, "config", "user.email", "t@t")
    _git(repo, "config", "user.name", "t")
    _outbox(repo, "mgf-common")
    _git(repo, "add", "-A")
    _git(repo, "commit", "-m", "outbox has a paper")
    # simulate origin/main == this commit (what a fresh fetch would show)
    _git(repo, "update-ref", "refs/remotes/origin/main", "HEAD")
    # now the working tree goes stale: the paper is deleted locally (uncommitted)
    (repo / "pending-feedback" / "mgf-common.md").unlink()
    # the working-tree read false-greens (the blind spot) …
    assert scan_repo(repo, "clone", fresh=False) == []
    # … but --fresh sees origin/main and still finds the paper
    fresh = scan_repo(repo, "clone", fresh=True)
    assert len(fresh) == 1 and fresh[0].target == "mgf-common"
    # and the CLI gate fires non-zero with --fresh, greens without it
    assert run_feedback(in_repo=str(repo), fresh=True) == 1
    assert run_feedback(in_repo=str(repo), fresh=False) == 0


def test_fresh_falls_back_to_working_tree_without_origin_main(tmp_path: Path) -> None:
    """No git repo / no origin/main → --fresh must NOT report empty (that would false-green); it
    degrades to the working tree. Runs everywhere (git present or not)."""
    repo = tmp_path / "norepo"
    _outbox(repo, "mgf-common")  # a working-tree paper exists, but there's no origin/main
    fresh = scan_repo(repo, "norepo", fresh=True)
    assert len(fresh) == 1  # fell back to the working tree — did NOT false-green
    assert scan_repo(repo, "norepo", fresh=False)  # plain working-tree read still works


def test_fresh_degrades_when_git_missing(tmp_path: Path, monkeypatch, capsys) -> None:
    """The slim-CI-image case, forced deterministically: with no git binary, --fresh reads the
    working tree (never crashes with FileNotFoundError, never false-greens) and prints a note."""
    monkeypatch.setattr(_feedback.shutil, "which", lambda _name: None)  # git absent
    assert _feedback._git_available() is False
    repo = tmp_path / "app"
    _outbox(repo, "mgf-common")
    fresh = scan_repo(repo, "app", fresh=True)
    assert len(fresh) == 1  # degraded to the working tree
    # the CLI surfaces the fallback with a clear note and still fires the gate
    assert run_feedback(in_repo=str(repo), fresh=True) == 1
    out = capsys.readouterr().out
    assert "note:" in out and "git not on PATH" in out and "working tree" in out


def _empty_registry(root) -> None:
    """A federation root with a valid, member-less registry — the scan under test is
    about repos the registry does NOT list."""
    import json as _json

    std = root / "mgf-standard"
    std.mkdir(parents=True)
    (std / "MGF_LIBRARIES.json").write_text(
        _json.dumps({"cornerstone": "mgf-common", "libraries": []}), encoding="utf-8"
    )


def test_scan_sees_non_member_outboxes(tmp_path) -> None:
    """FEEDBACK PAPER-123: the consumer-app tier files feedback but is not in the
    registry, so a member-only scan printed a false green over a 🔴 paper."""
    from mgf.fed import _feedback

    root = tmp_path / "fed"
    _empty_registry(root)
    app = root / "demo-app" / "pending-feedback"
    app.mkdir(parents=True)
    (root / "demo-app" / ".git").mkdir()
    (app / "mgf-common.md").write_text(
        "# Pending feedback\n\n"
        "### PAPER — a real defect from a non-member app\n\n"
        "- **Severity:** 🔴 Blocker\n"
        "- **Status:** OPEN\n",
        encoding="utf-8",
    )
    papers = _feedback.scan(root)
    assert [p.source for p in papers] == ["demo-app"], "non-member outbox must be scanned"


def test_scan_ignores_a_dir_that_is_not_a_checkout(tmp_path) -> None:
    from mgf.fed import _feedback

    root = tmp_path / "fed"
    _empty_registry(root)
    (root / "scratch" / "pending-feedback").mkdir(parents=True)  # no .git
    (root / "scratch" / "pending-feedback" / "x.md").write_text("### PAPER — x\n", encoding="utf-8")
    assert _feedback.scan(root) == []


@requires_git
def test_fresh_reads_a_MASTER_default_repo_instead_of_degrading(tmp_path: Path) -> None:
    """Phase 3 §3.5. `--fresh` used to resolve the constant `origin/main`.

    On a repo whose default branch is `master` — figrecovery and the four sooth*
    repos — that ref never resolves, so `--fresh` fell back to the WORKING TREE
    on every single run and announced the reason "origin/main does not resolve":
    true, irrelevant, and sitting next to a perfectly good `origin/master`. The
    freshness guarantee CIR-16 (b) exists for was therefore off for those repos
    permanently, while the CLI printed a note that read like a transient.

    Same experiment as the origin/main test, one word changed.
    """
    repo = tmp_path / "clone-master"
    repo.mkdir()
    _git(repo, "init", "-b", "master")
    _git(repo, "config", "user.email", "t@t")
    _git(repo, "config", "user.name", "t")
    _outbox(repo, "mgf-common")
    _git(repo, "add", "-A")
    _git(repo, "commit", "-m", "outbox has a paper")
    _git(repo, "update-ref", "refs/remotes/origin/master", "HEAD")
    _git(repo, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/master")
    (repo / "pending-feedback" / "mgf-common.md").unlink()  # working tree goes stale

    assert scan_repo(repo, "clone-master", fresh=False) == []  # the blind spot
    fresh = scan_repo(repo, "clone-master", fresh=True)
    assert len(fresh) == 1 and fresh[0].target == "mgf-common", (
        "--fresh must resolve origin/master, not degrade because origin/main is absent"
    )
    assert run_feedback(in_repo=str(repo), fresh=True) == 1


@requires_git
def test_fresh_resolves_master_by_fallback_when_origin_HEAD_is_unset(tmp_path: Path) -> None:
    """Most clones in this estate have no `origin/HEAD` at all — the wave survey
    found it unset on all 30 repos it touched, which is why the default branch
    had to be asked of the REMOTE. The named fallbacks cover that case."""
    repo = tmp_path / "clone-nohead"
    repo.mkdir()
    _git(repo, "init", "-b", "master")
    _git(repo, "config", "user.email", "t@t")
    _git(repo, "config", "user.name", "t")
    _outbox(repo, "mgf-common")
    _git(repo, "add", "-A")
    _git(repo, "commit", "-m", "outbox has a paper")
    _git(repo, "update-ref", "refs/remotes/origin/master", "HEAD")
    # deliberately NO origin/HEAD symref
    (repo / "pending-feedback" / "mgf-common.md").unlink()

    fresh = scan_repo(repo, "clone-nohead", fresh=True)
    assert len(fresh) == 1, "the origin/master fallback must fire when origin/HEAD is unset"


@requires_git
def test_the_degradation_note_names_what_it_tried(tmp_path: Path) -> None:
    """A reduced scope announced for the WRONG reason is barely better than a
    silent one. The note must say what was looked for, not assert one name.

    `@requires_git` is load-bearing and was missing on the first attempt, which
    reddened CI on all three Python versions while the bench stayed green. The
    canonical CI image (`python:*-slim`) ships WITHOUT git, so
    `_fresh_unavailable_reason` correctly returns "git not on PATH" there — it
    reports the FIRST reason that applies, and git being absent outranks which
    refs were tried. This test is about the ref-resolution branch, so it only
    has a subject when git exists. The bench was the unrepresentative
    environment, exactly the way CI's slim image keeps proving.
    """
    repo = tmp_path / "norepo2"
    _outbox(repo, "mgf-common")
    reason = _feedback._fresh_unavailable_reason(repo)
    assert "origin/HEAD" in reason and "origin/master" in reason, reason


# ─────────────────────────────────────────────────────────────────────────────
# FB-05 outbox blindness (2026-08-09 court round `fb05-relay-blindness`).
#
# Every fixture below is a VERBATIM shape taken from one of the two real outboxes
# that sat undelivered — figrecovery's (1 paper, 1 day) and mgf-uitest-web's
# (4 papers, 24 days). The gate reported "No un-relayed feedback in any outbox ✓"
# and exited 0 over all five. Synthetic fixtures would not have caught this,
# because the bug was precisely that reality had a shape the fixtures did not.
# ─────────────────────────────────────────────────────────────────────────────

# mgf-uitest-web/pending-feedback/mgf-common.md — `##`, documented separator.
REAL_UITEST_WEB = """# pending-feedback → mgf-common

Outbound papers (FB-05 / DOC-19): findings from `mgf-uitest-web`.

---

## PAPER-01 — the generated `docs/claude/CLAUDE.md` asserts a dependency that need not exist

**Status:** OPEN · **Severity:** 🟡 (misleads every agent that reads it) · **Filed:** 2026-07-16

**What.** The render states, as fact, a dependency this repo does not have.

## PAPER-02 — DOC-12's `## §N.` heading mandate and fleet practice disagree

**Status:** OPEN · **Severity:** 🟢 · **Filed:** 2026-07-16

**What.** The mandate and what every repo actually ships are different.
"""

# figrecovery/pending-feedback/mgf-common.md — `##`, NO separator, an em-dash
# inside the title itself, and prose in the header that mentions other papers.
REAL_FIGRECOVERY = """# pending-feedback — `mgf-common`

> **Numbering is provisional.** The previous batch was drafted as 86/87
> and re-grepped to PAPER-158/159 at relay time (see `relayed/mgf-common.md`).

---

## PAPER-88 🟡 A child whose stderr is *expected* has no way to say so — `run()` always warns

**Severity: Papercut** — one probe, one warning, but it is the first line
of output a new user of the consumer ever sees.
"""


def test_a_double_hash_outbox_is_parsed_not_skipped() -> None:
    """The 24-day case: `##` headings yielded ZERO papers, indistinguishable from empty."""
    papers = _parse_outbox(REAL_UITEST_WEB, "mgf-uitest-web", "mgf-common", Path("mgf-common.md"))
    assert [p.title for p in papers] == [
        "the generated `docs/claude/CLAUDE.md` asserts a dependency that need not exist",
        "DOC-12's `## §N.` heading mandate and fleet practice disagree",
    ]


def test_a_title_carrying_its_own_em_dash_is_not_truncated_to_its_tail() -> None:
    """The old `PAPER…<sep><title>` search was NON-GREEDY, so a title containing ' — ' yielded
    only the words AFTER it — and that mangled title would be written into the target's
    permanent FEEDBACK.md. Assert the whole title survives, and name the exact wrong answer."""
    (paper,) = _parse_outbox(REAL_FIGRECOVERY, "figrecovery", "mgf-common", Path("mgf-common.md"))
    assert paper.title != "`run()` always warns"  # the pre-fix result
    assert paper.title == (
        "🟡 A child whose stderr is *expected* has no way to say so — `run()` always warns"
    )


def test_an_unknown_heading_depth_is_REPORTED_not_silently_empty() -> None:
    """The durable half: widening the split fixes today's two files. This fixes the class —
    a heading shape the parser does not know must never look like an empty outbox."""
    body = REAL_UITEST_WEB.replace("## PAPER-", "#### PAPER-")
    assert _parse_outbox(body, "x", "mgf-common", Path("f.md")) == []
    reason = _feedback.unparseable_reason(body)
    assert reason is not None
    assert "2 PAPER heading(s) present but only 0 parsed" in reason


def test_a_genuinely_empty_outbox_is_not_reported_as_a_problem() -> None:
    """'Verified empty' must stay distinct from 'could not read' in BOTH directions."""
    assert _feedback.unparseable_reason("# pending-feedback → mgf-common\n\nNothing yet.\n") is None


def test_prose_naming_a_paper_number_is_not_mistaken_for_a_heading() -> None:
    """figrecovery's real header says 're-grepped to PAPER-158/159 at relay time'. A body whose
    only PAPER mentions are prose must not be flagged — a false FAIL here reddens the fleet."""
    header_only = REAL_FIGRECOVERY.split("## PAPER-88")[0]
    assert "PAPER-158/159" in header_only  # the trap is really present
    assert _feedback.unparseable_reason(header_only) is None


def test_a_well_formed_outbox_is_never_flagged() -> None:
    assert _feedback.unparseable_reason(OUTBOX) is None


def test_the_gate_EXITS_NON_ZERO_when_an_outbox_cannot_be_read(tmp_path: Path, capsys) -> None:
    """The whole finding, end to end: before this, run_feedback printed the ✓ line and
    returned 0 over an outbox full of papers it could not parse."""
    consumer = tmp_path / "app"
    _outbox(consumer, "mgf-common", REAL_UITEST_WEB.replace("## PAPER-", "#### PAPER-"))
    rc = run_feedback(in_repo=str(consumer))
    out = capsys.readouterr().out
    assert rc == 2, (
        "an unreadable outbox must not share an exit code with a clean scan (0) "
        "OR with ordinary un-relayed debt (1) — board item 39a"
    )
    assert "No un-relayed feedback in any outbox ✓" not in out
    assert "Unreadable outbox" in out
    assert "COULD NOT BE READ" in out


def test_the_relayed_entry_carries_the_whole_title_and_drops_the_heading() -> None:
    """A mangled title is worse than a skip: it lands in the target's permanent FEEDBACK.md."""
    (paper,) = _parse_outbox(REAL_FIGRECOVERY, "figrecovery", "mgf-common", Path("mgf-common.md"))
    entry = _render_inbox_entry(paper, 160)
    assert entry.startswith("### PAPER-160 — 🟡 A child whose stderr is *expected*")
    assert "## PAPER-88" not in entry  # the source heading is stripped, not duplicated
    assert "first line" in entry  # the body survives verbatim


# ─────────────────────────────────────────────────────────────────────────────
# Chair findings F-1..F-11, answered by ID. The first cut of this fix was
# returned UNSOUND: it recovered the five papers but made the relay path
# DESTRUCTIVE, and the state it invented to end silent greens still returned
# None on the most reachable version of the bug.
# ─────────────────────────────────────────────────────────────────────────────

MIXED_DEPTH = """# pending-feedback → mgf-common

### PAPER (→ mgf-common) — the documented depth

**Severity:** 🟢 fine

#### PAPER-02 — a RED one at the wrong depth

**Severity:** 🔴 blocker
"""


def test_F1_a_mixed_depth_outbox_is_reported_not_silently_absorbed() -> None:
    """F-1 (BLOCKER). `unparseable_reason` granted amnesty per FILE — it returned None as soon
    as ANY heading split. The unparsed block is absorbed into the previous paper's body, so
    --relay would delete it from the source and bury it inside another paper in the target."""
    papers = _parse_outbox(MIXED_DEPTH, "app", "mgf-common", Path("mgf-common.md"))
    assert len(papers) == 1
    reason = _feedback.unparseable_reason(MIXED_DEPTH)
    assert reason is not None, "one heading parsing must not excuse the one that did not"
    assert "3 PAPER heading(s)" not in reason  # exactly 2 headings here
    assert "only 1 parsed" in reason
    assert "a RED one at the wrong depth" in reason  # names the paper it cannot read


def test_F2_the_block_is_verbatim_so_relay_can_drain_a_double_hash_source() -> None:
    """F-2 (BLOCKER). `block` was normalised to `###`, but `_relay_one` removes the paper with
    `src_text.replace(paper.block, "")` — a silent NO-OP against a `##` source. The paper would
    be appended to the target, never removed from the source, and DUPLICATED on the next run."""
    for body in (REAL_UITEST_WEB, REAL_FIGRECOVERY, OUTBOX):
        papers = _parse_outbox(body, "s", "mgf-common", Path("f.md"))
        # G-6: without this, mutating the parser to return NOTHING leaves the loops empty and
        # this test — the F-2 pin — passes. A check that can only observe the outcome you
        # hoped for is the same defect the whole round is about.
        assert papers, "no papers parsed: this test would otherwise pass vacuously"
        for paper in papers:
            assert paper.block in body, "the block must be removable from the source it came from"
            assert body.replace(paper.block, "") != body, "replace() must not be a no-op"


def test_F2_relay_actually_drains_the_source_and_does_not_duplicate(tmp_path: Path) -> None:
    """F-2, end to end on the real `##` shape: relay once, source drained; relay twice, no dupes."""
    root = tmp_path / "root"
    (root / "mgf-common").mkdir(parents=True)
    (root / "mgf-common" / "FEEDBACK.md").write_text("# FEEDBACK — mgf-common\n", "utf-8")
    src = root / "app"
    outbox = _outbox(src, "mgf-common", REAL_UITEST_WEB)
    (src / ".git").mkdir(parents=True, exist_ok=True)
    (root / "mgf-standard").mkdir(parents=True)
    (root / "mgf-standard" / "MGF_LIBRARIES.json").write_text(
        '{"release_waves": [], "libraries": []}', "utf-8"
    )

    assert run_feedback(relay=True, root=root) == 0
    assert _feedback._PAPER_HEADING.findall(outbox.read_text()) == [], "source must be drained"
    target_after_one = (root / "mgf-common" / "FEEDBACK.md").read_text()
    assert len(_feedback._PAPER_HEADING.findall(target_after_one)) == 2

    run_feedback(relay=True, root=root)  # second run must find nothing left
    assert (root / "mgf-common" / "FEEDBACK.md").read_text() == target_after_one


def test_F3_a_non_utf8_outbox_is_reported_not_smoothed_into_a_green(tmp_path: Path, capsys) -> None:
    """F-3 (BLOCKER). `errors="replace"` converted a loud UnicodeDecodeError into exit 0 —
    the exact silent green this whole change exists to abolish, introduced BY the fix."""
    app = tmp_path / "app"
    (app / "pending-feedback").mkdir(parents=True)
    (app / "pending-feedback" / "mgf-common.md").write_bytes(
        "### PAPER (→ mgf-common) — a red one\n\n**Severity:** 🔴\n".encode("utf-16")
    )
    rc = run_feedback(in_repo=str(app))
    out = capsys.readouterr().out
    assert rc == 2
    assert "not valid UTF-8" in out
    assert "No un-relayed feedback in any outbox ✓" not in out


def test_F4_relay_REFUSES_while_any_outbox_in_scope_is_unreadable(tmp_path: Path, capsys) -> None:
    """F-4. `problems` was computed in relay mode and never consulted — same ✓, same exit 0."""
    root = tmp_path / "root"
    (root / "mgf-standard").mkdir(parents=True)
    (root / "mgf-standard" / "MGF_LIBRARIES.json").write_text(
        '{"release_waves": [], "libraries": []}', "utf-8"
    )
    (root / "mgf-common").mkdir()
    (root / "mgf-common" / "FEEDBACK.md").write_text("# FEEDBACK\n", "utf-8")
    src = root / "app"
    _outbox(src, "mgf-common", MIXED_DEPTH)
    (src / ".git").mkdir(parents=True, exist_ok=True)
    # The WRITE paths refuse. --dry-run writes nothing, so it warns and proceeds (G-5) — see
    # test_G5_dry_run_is_not_refused_so_a_broken_outbox_can_still_be_inspected.
    for kwargs in ({}, {"target": "mgf-common"}):
        assert run_feedback(relay=True, root=root, **kwargs) == 1
        assert "REFUSING to relay" in capsys.readouterr().out


def test_F5_a_non_routing_parenthetical_is_not_eaten_from_the_title() -> None:
    """F-5. `_HEAD_ROUTE` ate ANY leading `(...)`, silently deleting a real `(draft)` on its way
    into the target's permanent FEEDBACK.md — the mangling moved from the tail to the head."""
    body = "## PAPER-01 (draft) the gate can go green without checking\n\n**Severity:** 🔴\n"
    (paper,) = _parse_outbox(body, "s", "t", Path("f.md"))
    assert paper.title == "(draft) the gate can go green without checking"
    # the documented routing parenthetical IS still stripped
    (routed,) = _parse_outbox("### PAPER (→ mgf-common) — real title\n", "s", "t", Path("f.md"))
    assert routed.title == "real title"


def test_F6_indented_and_unspaced_headings_are_PARSED_not_merely_detected() -> None:
    """F-6, revised by G-3. The first cut *reported* these as unreadable. That was the wrong
    half of the fix: a 0-3 space indent IS a heading in CommonMark and a missing space after
    the hashes is an obvious human slip, so the parser should READ them, not complain. What
    stays reportable is a depth the schema does not use at all (below)."""
    for body in (
        "# outbox\n\n  ### PAPER-01 — indented\n\n**Severity:** 🔴\n",
        "# outbox\n\n###PAPER-01 — no space after the hashes\n\n**Severity:** 🔴\n",
    ):
        assert len(_parse_outbox(body, "a", "b", Path("f.md"))) == 1, body
        assert _feedback.unparseable_reason(body) is None, body

    for body in (
        "# outbox\n\n# PAPER-01 — depth 1\n\n**Severity:** 🔴\n",
        "# outbox\n\n#### PAPER-01 — depth 4\n\n**Severity:** 🔴\n",
        "# outbox\n\n###### PAPER-01 — depth 6\n\n**Severity:** 🔴\n",
    ):
        assert _parse_outbox(body, "a", "b", Path("f.md")) == [], body
        assert _feedback.unparseable_reason(body) is not None, body


def test_F7_a_fenced_code_block_documenting_the_schema_is_not_a_false_FAIL() -> None:
    """F-7. A false FAIL here reddens the fleet gate for writing documentation."""
    body = (
        "# outbox\n\nHow to file a paper:\n\n"
        "```markdown\n#### PAPER-NN — <title>\n```\n\nNothing pending.\n"
    )
    assert _feedback.unparseable_reason(body) is None


def test_F8_the_FLEET_wide_problem_scan_reaches_an_exit_code(tmp_path: Path, capsys) -> None:
    """F-8. Mutating `scan_problems`/fleet-mode to return [] failed NOTHING in the 1444-test
    suite: the only end-to-end test used `in_repo`, so the fleet half was pure decoration."""
    root = tmp_path / "root"
    (root / "mgf-standard").mkdir(parents=True)
    (root / "mgf-standard" / "MGF_LIBRARIES.json").write_text(
        '{"release_waves": [], "libraries": []}', "utf-8"
    )
    src = root / "consumer"
    _outbox(src, "mgf-common", MIXED_DEPTH)
    (src / ".git").mkdir(parents=True, exist_ok=True)
    assert len(_feedback.scan_problems(root)) == 1
    assert run_feedback(root=root) == 2  # could-not-read, not ordinary debt (39a)
    assert "Unreadable outbox" in capsys.readouterr().out


def test_F9_one_unreadable_file_does_not_abort_the_walk(tmp_path: Path) -> None:
    """F-9. A single unreadable outbox raised out of the scan, so every OTHER repo in the fleet
    was neither verified nor reported — while rc=1 looked like ordinary FB-05 debt.

    The unreadable file is a DIRECTORY named ``*.md`` (``IsADirectoryError``), not a chmod-000
    file. The first cut of this test used ``chmod(0o000)`` and passed on the bench as uid 1000
    while failing in CI, where pytest runs as **root** and mode bits do not deny root — the
    identical shape as an earlier gate whose test asserted a property of uid 1000. This
    mechanism is uid-independent, so the test means the same thing in both environments.
    """
    app = tmp_path / "app"
    (app / "pending-feedback").mkdir(parents=True)
    (app / "pending-feedback" / "mgf-http.md").write_text(OUTBOX, "utf-8")
    (app / "pending-feedback" / "mgf-common.md").mkdir()  # a dir where a file belongs
    papers, problems = _feedback.scan_repo_all(app, "app")
    assert len(papers) == 1, "the readable outbox beside it must still be scanned"
    assert len(problems) == 1
    assert "IsADirectoryError" in problems[0].reason


@pytest.mark.skipif(os.geteuid() == 0, reason="mode bits do not deny root; see the F-9 test")
def test_F9_permission_denied_is_reported_too(tmp_path: Path) -> None:
    """F-9, the PermissionError arm specifically. Skipped as root — where chmod cannot create
    the condition at all — rather than silently asserting something that cannot fail there."""
    app = tmp_path / "app"
    (app / "pending-feedback").mkdir(parents=True)
    bad = app / "pending-feedback" / "mgf-common.md"
    bad.write_text("### PAPER-01 — x\n", "utf-8")
    bad.chmod(0o000)
    try:
        papers, problems = _feedback.scan_repo_all(app, "app")
    finally:
        bad.chmod(0o644)
    assert papers == []
    assert len(problems) == 1 and "PermissionError" in problems[0].reason


def test_F11_papers_and_problems_come_from_ONE_walk(tmp_path: Path, monkeypatch) -> None:
    """F-11. Two independent walks were claimed to be one; --fresh paid 2x the git cost and the
    'can never disagree' invariant was an accident of argument-passing."""
    app = tmp_path / "app"
    _outbox(app, "mgf-common", MIXED_DEPTH)
    calls: list[Path] = []
    real = _feedback.outbox_bodies
    monkeypatch.setattr(
        _feedback,
        "outbox_bodies",
        lambda repo_dir, **kw: (calls.append(repo_dir), real(repo_dir, **kw))[1],
    )
    papers, problems = _feedback.scan_repo_all(app, "app")
    assert len(calls) == 1, "one walk, not one per result kind"
    assert papers and problems


# ── Round-2 chair findings G-1..G-6 ──────────────────────────────────────────
# The second cut was ALSO returned UNSOUND: the count check compared three
# different counters over two different views of the text, so the destruction
# path survived and reached exit 0 again.


def test_G1_a_prefix_chunk_is_not_counted_as_a_parsed_paper() -> None:
    """G-1a (BLOCKER). `parsed` came from `_PAPER_SPLIT.split`, which counts the PREFIX chunk
    when a file happens to begin with the word PAPER — though that chunk yields no paper. Two
    wrong counters agreed and a 🔴 paper was reported CLEAN at exit 0."""
    body = "PAPER numbering is provisional.\n\n#### PAPER-02 — REL-13 is RED\n\n**Severity:** 🔴\n"
    assert _parse_outbox(body, "a", "b", Path("f.md")) == []
    reason = _feedback.unparseable_reason(body)
    assert reason is not None, "a red paper is present; clean is the one answer that is wrong"
    assert "only 0 parsed" in reason


def test_G1_parsed_never_exceeds_headings_so_the_message_cannot_contradict_itself() -> None:
    """G-4. `parsed > headings` produced messages like '0 heading(s) present but only 1 parsed'."""
    bodies = [
        "PAPER at byte zero\n\n## PAPER-01 — real\n",
        "# outbox\n\n```markdown\n### PAPER (→ t) — <title>\n```\n\n## PAPER-01 — real\n",
        MIXED_DEPTH,
        REAL_FIGRECOVERY,
        REAL_UITEST_WEB,
    ]
    for body in bodies:
        papers = len(_parse_outbox(body, "a", "b", Path("f.md")))
        headings = len(_feedback._outside_fences(body, _feedback._PAPER_HEADING))
        assert papers <= headings, body[:40]


def test_G2_a_fenced_schema_example_at_the_DOCUMENTED_depth_is_not_a_paper() -> None:
    """G-2. Fences were stripped for DETECTION but not for PARSING, so a fenced `###` example
    minted a PHANTOM paper (title '<title>', severity scavenged from the next paper) that
    --relay would have written into a permanent FEEDBACK.md — and false-FAILed the fleet gate.
    The earlier test used `####`, the one depth that could not trip it."""
    for depth in ("##", "###"):
        body = f"# outbox\n\n```markdown\n{depth} PAPER (→ t) — <title>\n```\n\nNothing pending.\n"
        assert _parse_outbox(body, "a", "b", Path("f.md")) == [], depth
        assert _feedback.unparseable_reason(body) is None, depth


@pytest.mark.parametrize(
    ("label", "body", "papers", "clean"),
    [
        (
            "3-space indent is a heading",
            "# o\n\n   ### PAPER-01 — x\n\n**Severity:** 🔴\n",
            1,
            True,
        ),
        ("4-space indent is a code block", "# o\n\n    ### PAPER-01 — x\n\ntext\n", 0, True),
        ("tab indent is a code block", "# o\n\n\t### PAPER-01 — x\n\ntext\n", 0, True),
        ("longer closing fence", "# o\n\n```\n### PAPER-01 — x\n````\n\nNothing.\n", 0, True),
        (
            # The fence must actually CLOSE here. If the closer is required to match the
            # opener byte-for-byte, this fence stays open to EOF and swallows the real paper
            # below it — a silent loss the "0 papers, clean" shapes above cannot detect.
            "a real paper AFTER a longer-closed fence",
            "# o\n\n```\n### PAPER-99 — an example\n````\n\n### PAPER-01 — a REAL one\n\n"
            "**Severity:** 🔴\n",
            1,
            True,
        ),
        (
            "a real paper AFTER an indented fence",
            "# o\n\n ```\n### PAPER-99 — an example\n ```\n\n### PAPER-01 — a REAL one\n\n"
            "**Severity:** 🔴\n",
            1,
            True,
        ),
        ("indented fence", "# o\n\n ```\n### PAPER-01 — x\n ```\n\nNothing.\n", 0, True),
        # NB: an unterminated fence holding a PAPER heading is now REPORTED (H-1), not clean —
        # this case originally asserted the silent green H-1 identifies. It stays here to pin
        # that the *parse* still yields nothing; the reporting is pinned by the H-1 tests.
        ("unclosed fence runs to EOF", "# o\n\n```markdown\n### PAPER-01 — x\n", 0, False),
        ("tilde fence", "# o\n\n~~~\n### PAPER-01 — x\n~~~\n\nNothing.\n", 0, True),
    ],
)
def test_G3_commonmark_shapes_do_not_false_FAIL(
    label: str, body: str, papers: int, clean: bool
) -> None:
    """G-3. Eight CommonMark-legal shapes false-FAILed, which reddens the fleet gate for
    writing ordinary markdown; one minted a phantom paper."""
    assert len(_parse_outbox(body, "a", "b", Path("f.md"))) == papers, label
    assert (_feedback.unparseable_reason(body) is None) is clean, label


def test_G5_dry_run_is_not_refused_so_a_broken_outbox_can_still_be_inspected(
    tmp_path: Path, capsys
) -> None:
    """G-5. Refusing --dry-run took away the only tool for LOOKING at the outbox you are being
    told to fix. It writes nothing, so it stays available; only the write path is refused."""
    root = tmp_path / "root"
    (root / "mgf-standard").mkdir(parents=True)
    (root / "mgf-standard" / "MGF_LIBRARIES.json").write_text(
        '{"release_waves": [], "libraries": []}', "utf-8"
    )
    (root / "mgf-common").mkdir()
    (root / "mgf-common" / "FEEDBACK.md").write_text("# FEEDBACK\n", "utf-8")
    src = root / "app"
    _outbox(src, "mgf-common", MIXED_DEPTH)
    (src / ".git").mkdir(parents=True, exist_ok=True)

    assert run_feedback(relay=True, root=root) == 1  # the WRITE path is refused
    assert "REFUSING to relay" in capsys.readouterr().out

    run_feedback(relay=True, dry_run=True, root=root)  # …but the inspection path still runs
    out = capsys.readouterr().out
    assert "would refuse to relay" in out
    assert "writes nothing" in out
    assert not (src / "pending-feedback" / "relayed").exists(), "dry-run must write nothing"


# ── Round-3 chair findings H-1..H-3 ──────────────────────────────────────────
# `_fence_spans` was the highest-risk thing in the round-2 diff, and it shipped a
# NEW instance of the founding defect: a runaway fence removes papers from BOTH
# sides of the count, so the check agrees with itself about a region it decided
# not to look at, and reports clean at exit 0 over a 🔴 paper still in the bytes.

RED = "\n\n### PAPER-01 — REL-13's release gate is RED\n\n**Severity:** 🔴 blocker\n"


@pytest.mark.parametrize(
    ("label", "body"),
    [
        ("a stray lone fence line above a real paper", "# outbox\n\n```" + RED),
        ("an unclosed ```markdown fence", "# outbox\n\n```markdown" + RED),
        ("a ~~~ fence 'closed' with ```", "# outbox\n\n~~~\nx\n```" + RED),
        ("an unclosed fence with nothing after it but papers", "```\n" + RED),
    ],
)
def test_H1_a_runaway_fence_never_reports_clean(label: str, body: str) -> None:
    """H-1 (BLOCKER). One deleted ``` line hid a real paper in a real fleet outbox while the
    gate printed the ✓ line. The papers vanish from `parsed` AND `headings` together, so the
    equality holds — which is why closedness has to be carried out of `_fence_spans`."""
    assert _parse_outbox(body, "a", "b", Path("f.md")) == [], label
    reason = _feedback.unparseable_reason(body)
    assert reason is not None, f"{label}: a red paper is present; clean is the wrong answer"
    assert "unterminated code fence" in reason
    assert "PAPER heading(s)" in reason


def test_H1_a_CLOSED_fence_with_no_papers_in_it_is_still_clean() -> None:
    """The other direction: an ordinary documented outbox must not start failing."""
    body = "# outbox\n\n```markdown\n### PAPER-NN — <title>\n```\n\nNothing pending.\n"
    assert _feedback.unparseable_reason(body) is None
    # …and an unterminated fence holding NO paper headings is not this finding's business
    assert _feedback.unparseable_reason("# outbox\n\n```\njust an example\n") is None


def test_H2_a_CRLF_outbox_parses_its_papers(tmp_path: Path, capsys) -> None:
    """H-2. The closer pattern allowed `[ \\t]*$`, so on a CRLF file the closer is '```\\r' and
    `$` never matched — EVERY fence became runaway, i.e. H-1 by a different road."""
    body = "# outbox\r\n\r\n```\r\nan example\r\n```\r\n\r\n### PAPER-01 — a real one\r\n\r\n"
    body += "**Severity:** 🔴\r\n"
    (papers, problems) = (
        _parse_outbox(body, "a", "mgf-common", Path("f.md")),
        _feedback.unparseable_reason(body),
    )
    assert len(papers) == 1, "the fence closes on CRLF too"
    assert papers[0].title == "a real one"  # \r must not leak into the title
    assert problems is None
    # and the block stays removable from its CRLF source (the F-2 property)
    assert papers[0].block in body


def test_H3_a_backtick_in_the_info_string_loses_a_paper_but_is_REPORTED() -> None:
    """H-3. Deleting the CommonMark backtick-in-info rule failed ZERO of 1469 tests.

    I first asserted the real paper survives. It does not, and the chair said so: ```` ```a`b ````
    is correctly rejected as an opener, which promotes the *closing* fence to an opener that then
    runs to EOF and swallows the paper below. What H-1 buys is that this is now **loud** instead
    of a silent green — which is the whole bar. Asserting the survival I wanted would have been
    asserting a fiction."""
    body = "# outbox\n\n```a`b\nx\n```\n\n### PAPER-01 — a REAL one\n\n**Severity:** 🔴\n"
    assert _parse_outbox(body, "a", "b", Path("f.md")) == []
    reason = _feedback.unparseable_reason(body)
    assert reason is not None, "losing a paper silently is the one outcome that is unacceptable"
    assert "unterminated code fence" in reason


# ── Round-4 chair findings I-1 / I-2 ─────────────────────────────────────────
# Both blockers were `paper.block` (a STRING) matched against a file that had
# been re-read — and rewritten — since the slice was taken. The scan read
# `read_bytes().decode()`; the relay read `read_text()` (universal newlines) and
# then `.rstrip()`ed the file it was midway through mutating. Two views of one
# file, one layer below where round 3 found the same shape.


def _relay_fixture(tmp_path: Path, body: str) -> tuple[Path, Path, Path]:
    """A minimal federation root: one source outbox, one target inbox."""
    root = tmp_path / "root"
    (root / "mgf-standard").mkdir(parents=True)
    (root / "mgf-standard" / "MGF_LIBRARIES.json").write_text(
        '{"release_waves": [], "libraries": []}', "utf-8"
    )
    (root / "mgf-common").mkdir()
    inbox = root / "mgf-common" / "FEEDBACK.md"
    inbox.write_text("# FEEDBACK — mgf-common\n", "utf-8")
    src = root / "app"
    (src / "pending-feedback").mkdir(parents=True)
    (src / ".git").mkdir(parents=True, exist_ok=True)
    outbox = src / "pending-feedback" / "mgf-common.md"
    outbox.write_bytes(body.encode("utf-8"))  # bytes: a CRLF body must survive verbatim
    return root, outbox, inbox


TWO_PAPERS = (
    "# outbox\n\n### PAPER-01 — the first one\n\n**Severity:** 🟢 nit\n\n"
    "### PAPER-02 — REL-13's release gate is RED\n\n**Severity:** 🔴 blocker\n"
)


def test_I1_a_CRLF_outbox_is_actually_DRAINED_and_never_duplicated(tmp_path: Path) -> None:
    """I-1 (BLOCKER). `read_text()` normalised `\\r\\n` → `\\n`, so `replace()` matched nothing,
    the tool printed 'moved source paper', and a SECOND relay wrote duplicate entries into a
    permanent FEEDBACK.md — after which `--check` reported clean, so the corruption healed
    into green and nobody would ever learn."""
    root, outbox, inbox = _relay_fixture(tmp_path, TWO_PAPERS.replace("\n", "\r\n"))
    assert run_feedback(relay=True, root=root) == 0
    assert _feedback._PAPER_HEADING.findall(outbox.read_bytes().decode()) == [], "source drained"
    after_one = inbox.read_text(encoding="utf-8")
    assert len(_feedback._PAPER_HEADING.findall(after_one)) == 2
    run_feedback(relay=True, root=root)  # a second run must be a no-op
    assert inbox.read_text(encoding="utf-8") == after_one, "no duplicate entries"


def test_I2_trailing_blank_lines_do_not_strand_the_LAST_paper(tmp_path: Path) -> None:
    """I-2 (BLOCKER, a REGRESSION at the previous pin). `.rstrip()` deleted the file's trailing
    blank lines, but the LAST paper's raw slice runs to EOF and CONTAINS them — so relaying
    paper 1 made paper 2 unmatchable. Plain LF; nothing exotic. Verified against the previous
    pin: it left 1 paper undrained where this leaves 0."""
    for tail in ("\n\n", "\n\n\n", "   \n", "\n \n\n"):
        root, outbox, inbox = _relay_fixture(
            tmp_path / tail.replace("\n", "n").replace(" ", "s"), TWO_PAPERS + tail
        )
        assert run_feedback(relay=True, root=root) == 0, tail
        left = _feedback._PAPER_HEADING.findall(outbox.read_bytes().decode())
        assert left == [], f"tail={tail!r}: the last paper was not drained"
        assert len(_feedback._PAPER_HEADING.findall(inbox.read_text(encoding="utf-8"))) == 2


def test_I1_an_unremovable_block_RAISES_instead_of_claiming_success(tmp_path: Path) -> None:
    """The shape under both blockers: `replace()` returning the string unchanged was a SILENT
    no-op that printed success. It must be loud — the per-paper handler then leaves the paper
    in its outbox and the run exits non-zero."""
    root, outbox, inbox = _relay_fixture(tmp_path, TWO_PAPERS)
    papers = _feedback.scan(root)
    assert papers, "fixture must produce papers"
    outbox.write_text("# outbox\n\nsomeone rewrote this file\n", "utf-8")  # block now absent
    with pytest.raises(OSError, match="no longer among the papers parsed"):
        _feedback._relay_one(papers[0], 1, root, dry_run=False)
    assert "PAPER-01" not in inbox.read_text(encoding="utf-8"), (
        "the target must not be appended when the source cannot be drained"
    )


def test_I1_only_the_relayed_paper_is_removed_when_two_are_identical(tmp_path: Path) -> None:
    """Sabotage gap: `replace(block, "")` vs `replace(block, "", 1)` was pinned by nothing.
    Two byte-identical papers share a `block`, so removing ALL occurrences deletes the second
    from the source while only the first was ever appended to the target — a silent loss."""
    dup = "### PAPER-01 — the same text\n\n**Severity:** 🔴\n\n"
    root, outbox, _inbox = _relay_fixture(tmp_path, "# outbox\n\n" + dup + dup)
    papers = _feedback.scan(root)
    assert len(papers) == 2 and papers[0].block == papers[1].block, "fixture must be identical"
    _feedback._relay_one(papers[0], 1, root, dry_run=False)
    left = _feedback._PAPER_HEADING.findall(outbox.read_bytes().decode())
    assert len(left) == 1, "relaying one paper must not delete its twin from the source"


def test_I1_a_failed_source_write_rolls_the_inbox_back(tmp_path: Path, monkeypatch) -> None:
    """The inbox is appended before the source is drained (so an unwritable target cannot
    strand a drained source). That leaves one window: the source write failing AFTER the
    inbox write. Without the rollback the target keeps an entry the source still holds — the
    duplicate this whole finding is about, arrived by the other road.

    Fault-injected rather than chmod-based: uid-independent, so it means the same thing as
    root in CI as it does here.

    Injected at the RENAME, not the write. The source write is atomic (temp file +
    ``Path.replace``), so a `write_text` patch on the outbox path would never fire — and a test
    that cannot fire is the defect this whole round keeps finding.
    """
    root, outbox, inbox = _relay_fixture(tmp_path, TWO_PAPERS)
    before = inbox.read_text(encoding="utf-8")
    papers = _feedback.scan(root)
    real_replace = Path.replace

    def boom(self: Path, target: object) -> Path:
        if Path(target) == outbox:  # type: ignore[arg-type]
            raise OSError("disk full")
        return real_replace(self, target)  # type: ignore[arg-type]

    monkeypatch.setattr(Path, "replace", boom)
    with pytest.raises(OSError, match="disk full"):
        _feedback._relay_one(papers[0], 1, root, dry_run=False)
    monkeypatch.undo()
    assert inbox.read_text(encoding="utf-8") == before, (
        "the inbox must not keep an entry whose source was never drained"
    )


# ── Round-5 chair findings J-1..J-4 ──────────────────────────────────────────
# The block was still located by CONTENT. Verification proved *something* was
# removed, never that the RIGHT thing was — and `Path.write_text` truncates, so
# a mid-write ENOSPC emptied the outbox and the rollback then removed the last
# surviving copy. Located by offset, written atomically, committed in an order
# where any single failure leaves nothing behind.

FENCED_TWIN = (
    "# outbox\n\nExample of the shape:\n\n```markdown\n"
    "### PAPER-01 — the same words\n\n**Severity:** 🔴\n\n```\n\n"
    "### PAPER-01 — the same words\n\n**Severity:** 🔴\n"
)


def test_J1_a_fenced_example_identical_to_a_paper_is_not_the_thing_removed(
    tmp_path: Path,
) -> None:
    """J-1 (BLOCKER). `replace(block, "", 1)` deleted the EXAMPLE and left the paper, so the
    next run appended the paper a second time — two entries in a permanent FEEDBACK.md for one
    paper. Splicing by offset among *parsed* papers kills the class: a fenced example is never
    a parsed paper, so it can never be selected."""
    root, outbox, inbox = _relay_fixture(tmp_path, FENCED_TWIN)
    assert run_feedback(relay=True, root=root) == 0
    src = outbox.read_bytes().decode()
    assert "```markdown" in src, "the fenced example must be untouched"
    assert _parse_outbox(src, "a", "b", outbox) == [], "the real paper must be drained"
    after_one = inbox.read_text(encoding="utf-8")
    run_feedback(relay=True, root=root)
    assert inbox.read_text(encoding="utf-8") == after_one, "no duplicate on a second run"


def test_J2_a_truncating_mid_write_failure_cannot_lose_both_copies(
    tmp_path: Path, monkeypatch
) -> None:
    """J-2 (BLOCKER). `Path.write_text` truncates BEFORE writing, so ENOSPC left the outbox at
    zero bytes — and the inbox rollback then removed the last surviving copy. Total loss, and
    `--check` afterwards reported clean. The write is atomic now, so the rollback is safe."""
    root, outbox, _inbox = _relay_fixture(tmp_path, TWO_PAPERS)
    before = outbox.read_bytes()
    papers = _feedback.scan(root)
    real = Path.write_text

    def enospc(self: Path, text: str, *a: object, **k: object) -> int:
        rc = real(self, text, *a, **k)  # type: ignore[arg-type]
        if self.name.endswith(".mgf-fed-tmp") and "pending-feedback" in str(self):
            raise OSError(28, "No space left on device")
        return rc

    monkeypatch.setattr(Path, "write_text", enospc)
    with pytest.raises(OSError):
        _feedback._relay_one(papers[0], 1, root, dry_run=False)
    monkeypatch.undo()
    assert outbox.read_bytes() == before, "the outbox must keep every byte it had"
    assert list(outbox.parent.glob("*.mgf-fed-tmp")) == [], "no temp file left behind"


def test_J3_a_relayed_stamp_failure_reports_the_truth(tmp_path: Path, monkeypatch) -> None:
    """J-3. The stamp was written AFTER both files were committed and outside the rollback, so
    a failure printed 'paper left in outbox' about a paper that had in fact been relayed —
    and the FB-05 relayed/ record was permanently missing. The stamp is now written first and
    unwound by any later failure, so the message is true whichever step fails."""
    root, outbox, inbox = _relay_fixture(tmp_path, TWO_PAPERS)
    papers = _feedback.scan(root)
    real = Path.write_text

    def readonly_relayed(self: Path, text: str, *a: object, **k: object) -> int:
        if "relayed" in str(self):
            raise OSError(30, "Read-only file system")
        return real(self, text, *a, **k)  # type: ignore[arg-type]

    monkeypatch.setattr(Path, "write_text", readonly_relayed)
    with pytest.raises(OSError):
        _feedback._relay_one(papers[0], 1, root, dry_run=False)
    monkeypatch.undo()
    assert _parse_outbox(outbox.read_bytes().decode(), "a", "b", outbox), (
        "the paper really is still in its outbox, as the failure message claims"
    )
    assert "PAPER-01" not in inbox.read_text(encoding="utf-8"), "nothing committed to the target"


def test_J4_a_CRLF_inbox_is_not_rewritten_wholesale(tmp_path: Path) -> None:
    """J-4. `read_text()` on the TARGET was I-1 one file over: an ordinary relay rewrote a CRLF
    FEEDBACK.md as all-LF — a silent WHOLE-FILE diff on a permanent record.

    The honest bound, not the one I first asserted: every pre-existing line keeps its CRLF. The
    file's LAST line loses its terminator to the `.rstrip()` that joins the new entry on, so the
    diff is one line at the join rather than every line in the file. Asserting more than that
    would be asserting something the code does not do.
    """
    root = tmp_path / "root"
    (root / "mgf-standard").mkdir(parents=True)
    (root / "mgf-standard" / "MGF_LIBRARIES.json").write_text(
        '{"release_waves": [], "libraries": []}', "utf-8"
    )
    (root / "mgf-common").mkdir()
    inbox = root / "mgf-common" / "FEEDBACK.md"
    inbox.write_bytes(b"# FEEDBACK\r\n\r\n### PAPER-01 - an existing one\r\n\r\nbody text\r\n")
    src = root / "app"
    (src / "pending-feedback").mkdir(parents=True)
    (src / ".git").mkdir(parents=True, exist_ok=True)
    (src / "pending-feedback" / "mgf-common.md").write_text(
        "# outbox\n\n### PAPER-02 — new\n\n**Severity:** 🔴\n", "utf-8"
    )
    assert run_feedback(relay=True, root=root) == 0
    after = inbox.read_bytes()
    assert b"# FEEDBACK\r\n" in after, "pre-existing CRLF must survive"
    assert b"### PAPER-01 - an existing one\r\n" in after
    assert after.count(b"\r\n") >= 4, "the file must not be rewritten wholesale as LF"


# ── Round-6 chair findings K-1 / K-2, and the --fresh arm ────────────────────


def test_K1_an_existing_relayed_record_is_never_overwritten(tmp_path: Path) -> None:
    """K-1. `number` comes from the TARGET's inbox, so a reset or hand-edited FEEDBACK.md can
    hand back a number this source already used — and `_atomic_write` would overwrite the FB-05
    record of who filed what and where it landed. That record has no second copy."""
    root, outbox, _inbox = _relay_fixture(tmp_path, TWO_PAPERS)
    relayed = outbox.parent / "relayed"
    relayed.mkdir()
    prior = relayed / "mgf-common-PAPER-01.md"
    prior.write_text("> **Relayed** earlier. Do not lose me.\n", "utf-8")
    papers = _feedback.scan(root)
    with pytest.raises(OSError, match="already exists"):
        _feedback._relay_one(papers[0], 1, root, dry_run=False)
    assert prior.read_text(encoding="utf-8") == "> **Relayed** earlier. Do not lose me.\n"


def test_K1_a_failed_relay_leaves_no_orphan_record_and_spares_earlier_ones(
    tmp_path: Path, monkeypatch
) -> None:
    """K-1, the rollback half — restated to the property that is actually REACHABLE.

    The chair asked for a guard so the rollback never deletes a record it did not create. I
    added one, then sabotage showed it failed nothing: the refusal above means `stamp_file`
    never exists when we reach the rollback, so the guard was dead code. Removed it rather than
    keep an unreachable branch that reads like a live one.

    What IS reachable and worth pinning: a failed relay leaves no ORPHAN stamp claiming a paper
    was relayed when it was not, and an unrelated earlier record is untouched.
    """
    root, outbox, _inbox = _relay_fixture(tmp_path, TWO_PAPERS)
    relayed = outbox.parent / "relayed"
    relayed.mkdir()
    prior = relayed / "mgf-common-PAPER-02.md"
    prior.write_text("> **Relayed** earlier.\n", "utf-8")
    papers = _feedback.scan(root)
    real = Path.replace

    def boom(self: Path, target: object) -> Path:
        if Path(target) == outbox:  # type: ignore[arg-type]
            raise OSError("disk full")
        return real(self, target)  # type: ignore[arg-type]

    monkeypatch.setattr(Path, "replace", boom)
    with pytest.raises(OSError, match="disk full"):
        _feedback._relay_one(papers[0], 1, root, dry_run=False)  # writes PAPER-01, not 02
    monkeypatch.undo()
    assert not (relayed / "mgf-common-PAPER-01.md").exists(), "no orphan record for a failed relay"
    assert prior.is_file(), "an unrelated earlier record is untouched"


def test_K2_atomic_write_preserves_the_target_file_mode(tmp_path: Path) -> None:
    """K-2. The temp file is created fresh under the umask and renamed over the target, so a
    restricted mode was silently widened on a file the estate had deliberately locked down."""
    p = tmp_path / "secret.md"
    p.write_text("before\n", "utf-8")
    p.chmod(0o600)
    _feedback._atomic_write(p, "after\n")
    assert p.read_text(encoding="utf-8") == "after\n"
    assert p.stat().st_mode & 0o777 == 0o600


def test_K4_selection_is_among_PARSED_papers_not_any_matching_region(tmp_path: Path) -> None:
    """K-4. The whole 'select among parsed papers' mechanism — the thing that kills J-1's class
    — rested on ONE test. This is a second, structurally different discriminator: PAPER-01's
    body quotes PAPER-02 inside a fence, so the fenced copy appears EARLIER in the file than
    the real PAPER-02. A content search finds the quote first; only restricting the candidate
    set to parsed papers picks the right region.

    (A fence *after* the last paper is genuinely part of that paper's body and travels with it —
    my first attempt at this test asserted otherwise and was wrong.)
    """
    quoted = "### PAPER-02 — second\n\n**Severity:** 🔴\n"
    body = f"# outbox\n\n### PAPER-01 — first\n\n```markdown\n{quoted}```\n\n{quoted}"
    root, outbox, _inbox = _relay_fixture(tmp_path, body)
    papers = _feedback.scan(root)
    assert [p.title for p in papers] == ["first", "second"]
    _feedback._relay_one(papers[1], 7, root, dry_run=False)  # relay PAPER-02 only
    src = outbox.read_bytes().decode()
    assert f"```markdown\n{quoted}```" in src, "the quote inside PAPER-01's body must survive"
    assert [p.title for p in _parse_outbox(src, "a", "b", outbox)] == ["first"]


def test_the_fresh_arm_reports_a_file_git_show_cannot_read(tmp_path: Path, monkeypatch) -> None:
    """`--fresh` is not hypothetical: TelePlasma's pipeline gates on
    `mgf-fed feedback --in-repo . --fresh`. When `ls-tree` lists an outbox that `git show` then
    refuses, the arm added in round 2 is the only thing between that gate and a silent
    under-report — and it survived five rounds of sabotage pinned by nothing.

    Fault-injected because no natural repo produces the condition (the chair could not build one
    either); a check that exists only in a branch no test reaches does not exist.
    """
    repo = tmp_path / "app"
    (repo / "pending-feedback").mkdir(parents=True)
    monkeypatch.setattr(_feedback, "_fresh_ref", lambda d: "origin/main")
    monkeypatch.setattr(
        _feedback, "_fresh_outbox_files", lambda d: ["mgf-common.md", "mgf-http.md"]
    )

    def fake_git_lines(repo_dir: Path, *args: str) -> list[str] | None:
        if args and args[0] == "show" and args[-1].endswith("mgf-common.md"):
            return None  # git show refuses this one blob
        return ["### PAPER (→ mgf-http) — a real one", "", "**Severity:** 🔴"]

    monkeypatch.setattr(_feedback, "_git_lines", fake_git_lines)
    papers, problems = _feedback.scan_repo_all(repo, "app", fresh=True)
    assert len(papers) == 1, "the readable blob is still scanned"
    assert len(problems) == 1, "the unreadable one must be REPORTED, not skipped"
    assert "git show" in problems[0].reason
    assert run_feedback(in_repo=str(repo), fresh=True) == 2  # could-not-read (39a)


# --- 39d: the crash no capsys test can see -------------------------------------


def test_non_ascii_stdout_does_not_kill_the_gate(tmp_path: Path) -> None:
    """Board item 39d, pinned by a REAL SUBPROCESS.

    Every message this CLI prints carries em dashes, arrows and severity emoji.
    Under `PYTHONUTF8=0` with an ASCII locale — the default in a bare container —
    `print` raised `UnicodeEncodeError` and the command died with an exit code
    that had nothing to do with what it checked.

    `capsys` cannot catch this: pytest's capture holds *text* and never encodes
    it, so the failing path does not exist under an in-process test. That is why
    this one shells out and forces the environment.
    """
    app = tmp_path / "app"
    _outbox(app, "mgf-common", "### PAPER (→ mgf-common) — a title — with ✓\n\n**Severity:** 🟡\n")
    proc = subprocess.run(  # noqa: S603 — argv is this interpreter and a tmp_path
        [sys.executable, "-m", "mgf.fed", "feedback", "--check", "--in-repo", str(app)],
        capture_output=True,
        text=True,
        env={**os.environ, "PYTHONUTF8": "0", "LC_ALL": "C", "LANG": "C"},
        timeout=120,
        check=False,
    )
    combined = proc.stdout + proc.stderr
    assert "UnicodeEncodeError" not in combined, combined[-600:]
    assert proc.returncode == 1, (
        f"expected the ordinary FB-05 debt code, got {proc.returncode} — "
        f"an encoding fault must not masquerade as a gate result:\n{combined[-600:]}"
    )
    assert "Un-relayed feedback" in proc.stdout


# --- 39c: a paper the detector cannot see is a paper the gate forgives ---------


@pytest.mark.parametrize(
    ("shape", "text"),
    [
        ("bold", "**PAPER (→ mgf-common) — bold heading**\n\n**Severity:** 🟡\n"),
        ("mixed-case", "### Paper (→ mgf-common) — mixed case\n\n**Severity:** 🟡\n"),
        ("setext", "PAPER (→ mgf-common) — setext\n=====\n\n**Severity:** 🟡\n"),
    ],
)
def test_an_unparseable_paper_shape_is_REPORTED_not_forgiven(shape: str, text: str) -> None:
    """`unparseable_reason` compares papers-parsed against papers-*intended*. A
    shape the heading detector cannot see makes both counts zero, so the file
    reads as an honest empty outbox — the gate forgives it by failing to notice
    it. Each of these parses to 0 papers and MUST still be reported."""
    reason = unparseable_reason(text)
    assert reason is not None, f"{shape} heading is invisible — the gate would report clean"
    assert "PAPER heading" in reason


def test_a_conforming_outbox_is_still_clean() -> None:
    """The counterpart: widening detection must not start crying wolf over the
    shape the recipe actually mandates."""
    assert unparseable_reason("### PAPER (→ mgf-common) — fine\n\n**Severity:** 🟡\n") is None


def test_the_word_paper_in_prose_is_not_a_heading() -> None:
    """`**Severity:**` and a sentence mentioning a paper must not count, or every
    outbox becomes permanently unparseable."""
    assert unparseable_reason("### PAPER (→ x) — t\n\nsee PAPER-99 and **paper** work\n") is None
