"""``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

_PAPER_SPLIT = re.compile(r"^### (?=PAPER\b)", re.MULTILINE)
_TITLE = re.compile(r"^### PAPER[^\n]*?\s[—-]\s+(?P<title>[^\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 ``### PAPER …`` markdown block
    file: Path  # the outbox file it lives in


def _parse_outbox(text: str, source: str, target: str, file: Path) -> list[OutboxPaper]:
    """Split a ``pending-feedback/<target>.md`` body into its ``### PAPER …`` blocks."""
    out: list[OutboxPaper] = []
    for chunk in _PAPER_SPLIT.split(text):
        if not chunk.startswith("PAPER"):
            continue
        block = "### " + chunk.rstrip() + "\n"
        title_m = _TITLE.search(block)
        sev_m = _SEVERITY.search(block)
        origin_m = _ORIGIN.search(block)
        out.append(
            OutboxPaper(
                source=source,
                target=target,
                title=(title_m.group("title").strip() if title_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,
            )
        )
    return out


_FRESH_REF = "origin/main"


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_supported(repo_dir: Path) -> bool:
    """True when ``--fresh`` can honestly read ``origin/main`` for ``repo_dir`` — i.e. git is on
    PATH AND ``origin/main`` 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 _git_lines(repo_dir, "rev-parse", "--verify", "--quiet", _FRESH_REF) 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)."""
    if not _git_available():
        return "git not on PATH"
    return f"{_FRESH_REF} does not resolve"


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."""
    lines = _git_lines(repo_dir, "ls-tree", "--name-only", f"{_FRESH_REF}:pending-feedback")
    if lines is None:
        return None
    return [name for name in lines if name.endswith(".md")]  # blobs only → relayed/ tree dropped


def _scan_working_tree(repo_dir: Path, repo: str) -> list[OutboxPaper]:
    """Outbox papers from ``repo_dir``'s ``pending-feedback/*.md`` on disk (top-level only)."""
    found: list[OutboxPaper] = []
    outbox = repo_dir / "pending-feedback"
    if not outbox.is_dir():
        return found
    for file in sorted(outbox.glob("*.md")):  # top-level only → skips relayed/ subdir
        found.extend(_parse_outbox(file.read_text(encoding="utf-8"), repo, file.stem, file))
    return found


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 ``origin/main`` via git (CIR-16 (b): a stale clone whose
    working tree is missing a just-pushed paper no longer false-greens). When freshness can't be
    honored (no git binary — the slim CI image — or no ``origin/main``), it **degrades to the
    working tree** rather than reporting an empty outbox; the CLI prints a note in that case.
    """
    if fresh and _fresh_supported(repo_dir):
        found: list[OutboxPaper] = []
        for name in sorted(_fresh_outbox_files(repo_dir) or []):
            lines = _git_lines(repo_dir, "show", f"{_FRESH_REF}:pending-feedback/{name}")
            if lines is None:
                continue
            text = "\n".join(lines)
            file = repo_dir / "pending-feedback" / name  # the on-disk path, for the xref/report
            found.extend(_parse_outbox(text, repo, Path(name).stem, file))
        return found
    return _scan_working_tree(repo_dir, repo)


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)).
    """
    base = root or federation_root()
    found: list[OutboxPaper] = []
    seen: set[str] = set()
    for member in members(base):
        seen.add(member.repo)
        found.extend(scan_repo(member.repo_dir, member.repo, fresh=fresh))
    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
        found.extend(scan_repo(repo_dir, name, fresh=fresh))
    return found


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 title line; keep the body bullets verbatim.
    body = _TITLE.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 _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())

    # 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_text(encoding="utf-8")
    feedback.write_text(existing.rstrip() + "\n\n" + entry, "utf-8")
    src_text = paper.file.read_text(encoding="utf-8")
    paper.file.write_text(src_text.replace(paper.block, "").rstrip() + "\n", "utf-8")
    relayed_dir = paper.file.parent / "relayed"
    relayed_dir.mkdir(exist_ok=True)
    stamp = f"> **Relayed** to {paper.target}#PAPER-{number:02d} by the supervisor (FB-05).\n\n"
    (relayed_dir / f"{paper.target}-PAPER-{number:02d}.md").write_text(stamp + paper.block, "utf-8")
    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 = scan_repo(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 = scan(base, fresh=fresh)
    if target:
        papers = [p for p in papers if p.target == target]

    if not relay:
        if not papers:
            print("No un-relayed feedback in any outbox ✓")
            return 0
        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).")
        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
