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

from __future__ import annotations

import shutil
import subprocess
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,
)
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:
    subprocess.run(  # noqa: S603 — fixed git argv over a tmp_path fixture
        ["git", "-C", str(repo), *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) == []
