"""Unit mirror for ``mgf.fed._roster`` (TS-01).

Focuses on the roster module's own surface — federation-root resolution, the
:class:`Member` dataclass properties, and registry loading/error handling.
(The wave-ordering integration is also exercised via ``test_status.py``.)
"""

from __future__ import annotations

import json
import shutil
import subprocess
from pathlib import Path

import pytest

from mgf.fed._roster import (
    CheckoutDivergence,
    Member,
    divergence_banner,
    divergent_checkouts,
    federation_root,
    load_registry,
    members,
    print_divergence_banner,
)


def _write_registry(root: Path, reg: dict[str, object]) -> Path:
    (root / "mgf-standard").mkdir(parents=True, exist_ok=True)
    path = root / "mgf-standard" / "MGF_LIBRARIES.json"
    path.write_text(json.dumps(reg), encoding="utf-8")
    return path


def test_federation_root_prefers_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    monkeypatch.setenv("MGF_ROOT", str(tmp_path))
    assert federation_root() == tmp_path


def test_federation_root_falls_back_to_cwd_when_no_default(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
    monkeypatch.delenv("MGF_ROOT", raising=False)
    monkeypatch.setattr(Path, "home", classmethod(lambda cls: tmp_path / "nonexistent-home"))
    monkeypatch.chdir(tmp_path)
    assert federation_root() == Path.cwd()


def test_member_properties() -> None:
    m = Member(
        repo="mgf-foo",
        lang="python",
        registry_version="1.2.3",
        extras=("dev", "standards"),
        root=Path("/srv/fleet"),
    )
    assert m.repo_dir == Path("/srv/fleet/mgf-foo")
    assert m.has_standards_extra is True
    assert Member(repo="mgf-bar", lang="python", registry_version="0").has_standards_extra is False


def test_load_registry_missing_raises(tmp_path: Path) -> None:
    with pytest.raises(FileNotFoundError, match="registry not found"):
        load_registry(tmp_path)


def test_kind_defaults_to_library_and_loads_when_declared(tmp_path: Path) -> None:
    """`kind` is the member's TIER, orthogonal to `lang` (PAPER-151).

    The default is load-bearing: the installed registry declares `kind` for NOBODY,
    so an absent key must leave every current member graded exactly as before.
    """
    _write_registry(
        tmp_path,
        {
            "release_waves": [["mgf-lib", "mgf-app"]],
            "libraries": [
                {"repo": "mgf-lib", "lang": "python", "version": "1.0.0"},  # no kind
                {"repo": "mgf-app", "lang": "python", "version": "0.1.0", "kind": "app"},
            ],
        },
    )
    by_repo = {m.repo: m for m in members(tmp_path)}
    assert by_repo["mgf-lib"].kind == "library"  # absent → library
    assert by_repo["mgf-app"].kind == "app"
    # the dataclass default agrees with the loader default
    assert Member(repo="mgf-x", lang="python", registry_version="0").kind == "library"


def test_members_sorts_unwaved_repos_last(tmp_path: Path) -> None:
    _write_registry(
        tmp_path,
        {
            "release_waves": [["mgf-common"]],
            "libraries": [
                {"repo": "mgf-orphan", "lang": "zig", "version": "0.1.0"},
                {"repo": "mgf-common", "lang": "python", "version": "0.47.0"},
            ],
        },
    )
    ms = members(tmp_path)
    assert [m.repo for m in ms] == ["mgf-common", "mgf-orphan"]
    assert ms[0].wave == 0
    assert ms[1].wave == -1  # not in any wave → sorts to the end


# ---------------------------------------------------------------------------
# PAPER-152 — the divergence banner.
#
# `registry --check` and `drift-check` resolve every member by reading WHATEVER IS
# CHECKED OUT on the local bench and report it as the fleet's state. On the founder's
# bench 51 of 53 registry members sit on parked feature branches, so the gates graded
# a fleet that exists on no remote — wrong in both directions at once, and the harm
# was the remediation text: "walk it forward + verify" against a CORRECT pin means
# downgrading it to match a stale working directory.
#
# Observed 2026-08-06: two false REDs naming TelePlasma, whose pins were exactly
# right. The banner is the paper's cheapest ask and the one it said would have saved
# the day alone. It changes NO exit code — these fixtures pin both halves of that.
#
# The real-repo fixtures below build REAL git repos with a real
# `refs/remotes/origin/HEAD`, because the production path resolves refs THROUGH git
# and a faked `_git` would only prove the parser agrees with the fixture — the
# "writer and checker confirm each other" trap. That costs coverage where git is
# absent (`python:*-slim` ships none), so they carry the house `requires_git`
# marker, as ~94 git-attributable tests in this suite already do. The image, not
# the tests, is the defect: filed at `pending-feedback/mgf-standard.md`
# (`frag-mgf-common-matrix.yml` is the one pipeline step that runs the suite
# WITHOUT the two git lines its sibling test steps carry).
#
# The two MUTE PINS at the end of this block never skip. They keep the feature
# covered in every environment, so a git-less runner cannot silently reduce this
# section to nothing — which is the failure shape a bare skipif invites.
# ---------------------------------------------------------------------------

#: The house convention (see ``test_audit.py`` / ``test_court.py`` / ``test_feedback.py``)
#: for tests that shell out to git. `shutil.which` NEVER raises; a bare
#: `subprocess.run(["git", ...])` raises FileNotFoundError when the binary is absent,
#: which fails the test instead of skipping it.
requires_git = pytest.mark.skipif(
    shutil.which("git") is None, reason="git not on PATH (e.g. the slim CI image)"
)


def _git(cwd: Path, *args: str) -> None:
    subprocess.run(  # noqa: S603 — fixed git argv over a tmp_path fixture
        ["git", "-C", str(cwd), *args],  # noqa: S607
        check=True,
        capture_output=True,
    )


def _seed_member(root: Path, name: str, *, diverged: bool) -> Path:
    """A real git repo with a real `refs/remotes/origin/HEAD`, no network.

    `update-ref` + `symbolic-ref` synthesise exactly what a `git clone` leaves
    behind, so the fixture exercises the production resolution path rather than a
    stand-in for it.
    """
    d = root / name
    d.mkdir(parents=True)
    _git(d, "init", "--quiet", "--initial-branch=main")
    _git(d, "config", "user.email", "fixture@example.invalid")
    _git(d, "config", "user.name", "fixture")
    _git(d, "config", "commit.gpgsign", "false")
    (d / "README.md").write_text("seed\n", encoding="utf-8")
    _git(d, "add", "-A")
    _git(d, "commit", "--quiet", "-m", "seed")
    head = subprocess.run(  # noqa: S603
        ["git", "-C", str(d), "rev-parse", "HEAD"],  # noqa: S607
        check=True,
        capture_output=True,
        text=True,
    ).stdout.strip()
    _git(d, "update-ref", "refs/remotes/origin/main", head)
    _git(d, "symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main")
    if diverged:
        _git(d, "checkout", "--quiet", "-b", "fix/parked-feature-branch")
        (d / "walk.txt").write_text("unmerged walk\n", encoding="utf-8")
        _git(d, "add", "-A")
        _git(d, "commit", "--quiet", "-m", "unshipped work")
    return d


@requires_git
class TestDivergenceBanner:
    def test_a_clean_roster_produces_NO_banner(self, tmp_path: Path) -> None:
        """The negative control, and the one that keeps the banner meaningful: a
        bench whose checkouts are all at origin/main must say nothing at all."""
        dirs = [_seed_member(tmp_path, n, diverged=False) for n in ("mgf-a", "mgf-b")]
        assert divergent_checkouts(dirs) == []
        assert divergence_banner([], len(dirs), subject="drift-check") == ""

    def test_a_seeded_divergent_member_is_NAMED(self, tmp_path: Path) -> None:
        """The paper's own scenario: one member parked on an unmerged feature branch
        among clean siblings. The banner must name the member, its branch, and the
        ref it was compared against — enough for a reader to distrust the verdict."""
        dirs = [
            _seed_member(tmp_path, "mgf-clean", diverged=False),
            _seed_member(tmp_path, "mgf-parked", diverged=True),
        ]
        rows = divergent_checkouts(dirs)
        assert [r.repo for r in rows] == ["mgf-parked"]
        row = rows[0]
        assert row.branch == "fix/parked-feature-branch"
        assert row.default_ref == "origin/main"
        assert (row.ahead, row.behind) == (1, 0)

        banner = divergence_banner(rows, len(dirs), subject="drift-check")
        assert "mgf-parked" in banner
        assert "fix/parked-feature-branch" in banner
        assert "origin/main" in banner
        assert "1 of 2" in banner
        assert "PAPER-152" in banner
        assert "mgf-clean" not in banner  # only the diverged members are named

    def test_behind_counts_too_a_checkout_behind_its_own_release(self, tmp_path: Path) -> None:
        """The under-recording direction — mgf-fed's own case on the filing bench:
        the checkout was BEHIND the release it had already cut, so the gate reported
        0.23.0 for a member that is 0.24.0 on origin/main."""
        d = _seed_member(tmp_path, "mgf-behind", diverged=False)
        (d / "next.txt").write_text("released\n", encoding="utf-8")
        _git(d, "add", "-A")
        _git(d, "commit", "--quiet", "-m", "the release the checkout has not got")
        released = subprocess.run(  # noqa: S603
            ["git", "-C", str(d), "rev-parse", "HEAD"],  # noqa: S607
            check=True,
            capture_output=True,
            text=True,
        ).stdout.strip()
        _git(d, "update-ref", "refs/remotes/origin/main", released)
        _git(d, "reset", "--quiet", "--hard", "HEAD~1")
        rows = divergent_checkouts([d])
        assert (rows[0].ahead, rows[0].behind) == (0, 1)
        assert "mgf-behind" in divergence_banner(rows, 1, subject="drift-check")

    def test_the_name_list_is_capped_with_a_suppressed_count_tail(self, tmp_path: Path) -> None:
        dirs = [_seed_member(tmp_path, f"mgf-{i:02d}", diverged=True) for i in range(11)]
        rows = divergent_checkouts(dirs)
        assert len(rows) == 11
        banner = divergence_banner(rows, len(dirs), subject="drift-check")
        assert "… and 3 more" in banner  # default cap is 8
        assert "MGF_FED_DIVERGENCE_LIMIT" in banner

    def test_the_cap_is_operator_overridable(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
    ) -> None:
        monkeypatch.setenv("MGF_FED_DIVERGENCE_LIMIT", "50")
        dirs = [_seed_member(tmp_path, f"mgf-{i:02d}", diverged=True) for i in range(11)]
        banner = divergence_banner(divergent_checkouts(dirs), len(dirs), subject="drift-check")
        assert "… and" not in banner
        assert "mgf-10" in banner

    def test_non_git_and_remoteless_directories_are_SKIPPED_not_guessed(
        self, tmp_path: Path
    ) -> None:
        """A banner that speculated would be the defect it exists to label."""
        plain = tmp_path / "not-a-repo"
        plain.mkdir()
        local_only = tmp_path / "no-origin"
        local_only.mkdir()
        _git(local_only, "init", "--quiet", "--initial-branch=main")
        _git(local_only, "config", "user.email", "fixture@example.invalid")
        _git(local_only, "config", "user.name", "fixture")
        _git(local_only, "config", "commit.gpgsign", "false")
        (local_only / "f.txt").write_text("x\n", encoding="utf-8")
        _git(local_only, "add", "-A")
        _git(local_only, "commit", "--quiet", "-m", "no remote at all")
        assert divergent_checkouts([plain, local_only]) == []

    def test_print_divergence_banner_is_silenced_by_the_opt_out(
        self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
    ) -> None:
        dirs = [_seed_member(tmp_path, "mgf-parked", diverged=True)]
        assert print_divergence_banner(dirs, subject="drift-check") != ""
        assert capsys.readouterr().err != ""  # loud on stderr (0.25.1)
        monkeypatch.setenv("MGF_FED_NO_DIVERGENCE_BANNER", "1")
        assert print_divergence_banner(dirs, subject="drift-check") == ""
        cap = capsys.readouterr()
        assert cap.out == "" and cap.err == ""


@requires_git
def test_drift_check_banners_a_parked_member_WITHOUT_changing_its_exit_code(
    tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
    """The end-to-end contract: the gate-adjacent output gains the banner and the
    verdict is byte-for-byte the decision it was. A banner that moved an exit code
    would be a new failure mode, which is exactly what the paper did NOT ask for."""
    from mgf.fed._drift import run_drift_check

    std = _seed_member(tmp_path, "mgf-standard", diverged=True)
    (std / "templates").mkdir()
    (std / "templates" / "policy.toml").write_text("schema = 1\npresent = []\n", encoding="utf-8")
    _write_registry(
        tmp_path,
        {
            "release_waves": [["mgf-standard"]],
            "libraries": [{"repo": "mgf-standard", "lang": "python", "version": "0.1.78"}],
        },
    )
    code = run_drift_check(shape="python", root=tmp_path)
    cap = capsys.readouterr()
    # STDERR, not stdout — 0.25.1. The banner's member lines are `- <text>`, the
    # exact shape `_audit.check_registry_consistency` parses out of a gate's stdout
    # into Findings, so on stdout a diagnostic about the BENCH was reported as
    # defects of the FLEET (8 of them, the banner's own cap) and reddened every
    # mgf-standard pipeline that installed 0.25.0.
    assert "DIVERGENT CHECKOUTS" in cap.err
    assert "mgf-standard" in cap.err
    assert "fix/parked-feature-branch" in cap.err
    assert "DIVERGENT CHECKOUTS" not in cap.out  # never on the parsed stream
    # The verdict itself is untouched — this member has no pipeline, so the gate floor
    # still fires and the exit code is still 1, banner or no banner.
    assert code == 1


# --- MUTE PINS — these two NEVER skip -------------------------------------
#
# A skipif is honest about what it cannot run and dishonest about what that costs:
# on a runner without git, everything above turns into green nothing, and the
# feature would ship with no coverage at all in the environment it actually runs
# in. These two pin the halves that need no git — which, not by accident, are the
# halves that matter most where git is missing.


def test_the_scan_degrades_to_SILENCE_when_git_is_absent(
    tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
) -> None:
    """The git-less runner's own contract, pinned WHERE IT BITES.

    `_git` catches OSError and returns None, so a missing binary must make the
    banner say nothing rather than explode — a diagnostic that crashed the tool it
    diagnoses would be worse than the silent false RED it exists to label. This is
    the exact condition `python:*-slim` puts the suite in, asserted without needing
    the binary whose absence is the subject.
    """

    def _no_git(*_a: object, **_kw: object) -> object:
        raise FileNotFoundError(2, "No such file or directory: 'git'")

    monkeypatch.setattr("mgf.fed._roster.subprocess.run", _no_git)
    member = tmp_path / "mgf-parked"
    (member / ".git").mkdir(parents=True)  # looks like a repo; git cannot answer
    assert divergent_checkouts([member]) == []
    assert print_divergence_banner([member], subject="drift-check") == ""
    cap = capsys.readouterr()
    assert cap.out == "" and cap.err == ""


def test_the_banner_RENDERS_without_touching_git(monkeypatch: pytest.MonkeyPatch) -> None:
    """Formatting, the cap and the suppressed-count tail, over synthetic rows.

    `divergence_banner` is pure — it takes rows and returns text — so every claim
    the banner makes to an operator is checkable with no binary at all. Keeping this
    git-free is what stops a slim-image runner from muting the whole section.
    """
    rows = [
        CheckoutDivergence(
            repo=f"mgf-{i:02d}",
            branch="fix/parked-feature-branch",
            default_ref="origin/main",
            ahead=1,
            behind=0,
        )
        for i in range(11)
    ]
    banner = divergence_banner(rows, 53, subject="drift-check")
    assert "DIVERGENT CHECKOUTS" in banner
    assert "11 of 53" in banner
    assert "PAPER-152" in banner
    assert "mgf-00 — on fix/parked-feature-branch, ahead 1 / behind 0 of origin/main" in banner
    assert "… and 3 more" in banner  # default cap of 8
    assert "no fetch was performed" in banner
    # the operator's override, and the empty case — both git-free
    monkeypatch.setenv("MGF_FED_DIVERGENCE_LIMIT", "50")
    assert "… and" not in divergence_banner(rows, 53, subject="drift-check")
    assert divergence_banner([], 53, subject="drift-check") == ""
