"""The federation roster + release-wave order.

Source of truth is the ``mgf-standard`` registry (``MGF_LIBRARIES.json``), read from
the sibling repo on disk so v0 needs no install of ``mgf-standard``. The same data is
also resolvable at runtime via ``import mgf.standard`` once that becomes convenient.
"""

from __future__ import annotations

import json
import os
import subprocess
import sys
from collections.abc import Iterable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any


def federation_root() -> Path:
    """The directory holding the sibling mgf-* repos.

    Resolution order: ``$MGF_ROOT`` → ``~/PycharmProjects`` → cwd.
    """
    env = os.environ.get("MGF_ROOT")
    if env:
        return Path(env).expanduser()
    default = Path.home() / "PycharmProjects"
    return default if default.is_dir() else Path.cwd()


def is_git_repo(path: Path) -> bool:
    """Is ``path`` a git checkout — clone **or** linked worktree?

    ``.git`` is a DIRECTORY in a normal clone and a FILE (``gitdir: …``) in a linked
    worktree, so ``(path / ".git").is_dir()`` answers "no" for a worktree that git
    itself calls ``--is-inside-work-tree true``. That is PAPER-144: the roster tests
    silently DROPPED every linked worktree, so a repo checked out that way was not
    audited and produced no finding — "I could not look" wearing the costume of
    "there was nothing to look at".

    Existence is the right predicate, and it is the one this module, ``_doctor``,
    ``_drift``, ``_origin_view`` and ``_feedback`` already used; ``_audit`` and
    ``_court`` were the stragglers. Callers that need certainty (rather than a fast
    roster filter over a glob) should ask git directly.
    """
    return (path / ".git").exists()


def _registry_path(root: Path) -> Path:
    return root / "mgf-standard" / "MGF_LIBRARIES.json"


@dataclass(frozen=True, slots=True)
class Member:
    """One federation member as the registry sees it."""

    repo: str  # == published package name == Codeberg repo slug
    lang: str  # "python" | "zig" | "other"
    registry_version: str
    #: The member's TIER, orthogonal to `lang`: "library" (publishes a package the
    #: fleet installs) or "app" (a consumer product — publishes nothing to devpi,
    #: builds no wheel, cuts no tag to verify). Language answers "what is it written
    #: in"; kind answers "what is it FOR", and several gates need the second question
    #: (PAPER-151: a Python app was held to a library-shaped CI step-list it can never
    #: satisfy, because `lang == "python"` was the only tier signal available).
    #: Optional in `MGF_LIBRARIES.json` and defaulting to "library", so a registry
    #: that declares it for nobody — every registry up to mgf-standard 0.1.77 —
    #: grades exactly as it did before.
    kind: str = "library"
    extras: tuple[str, ...] = ()
    deps: tuple[str, ...] = ()  # mgf-* deps only
    used_by: tuple[str, ...] = ()
    wave: int = -1
    root: Path = field(default_factory=federation_root)

    @property
    def repo_dir(self) -> Path:
        return self.root / self.repo

    @property
    def has_standards_extra(self) -> bool:
        return "standards" in self.extras


def load_registry(root: Path | None = None) -> dict[str, Any]:
    root = root or federation_root()
    path = _registry_path(root)
    if not path.is_file():
        raise FileNotFoundError(
            f"registry not found at {path} — is MGF_ROOT correct, and mgf-standard cloned there?"
        )
    data: dict[str, Any] = json.loads(path.read_text(encoding="utf-8"))
    return data


def members(root: Path | None = None) -> list[Member]:
    """Every federation member, each tagged with its release wave."""
    root = root or federation_root()
    data = load_registry(root)
    waves: list[list[str]] = list(data.get("release_waves", []))
    wave_of: dict[str, int] = {repo: i for i, w in enumerate(waves) for repo in w}
    out: list[Member] = []
    for lib in data.get("libraries", []):
        repo = str(lib["repo"])
        out.append(
            Member(
                repo=repo,
                lang=str(lib.get("lang", "other")),
                registry_version=str(lib.get("version", "?")),
                kind=str(lib.get("kind", "library")),
                extras=tuple(lib.get("extras", []) or ()),
                deps=tuple((lib.get("deps", {}) or {}).keys()),
                used_by=tuple(lib.get("used_by", []) or ()),
                wave=wave_of.get(repo, -1),
                root=root,
            )
        )
    out.sort(key=lambda m: (m.wave if m.wave >= 0 else 99, m.repo))
    return out


# ---------------------------------------------------------------------------
# Checkout-divergence banner (PAPER-152, half one of two).
#
# `registry --check` and `drift-check` both resolve federation members by reading
# WHATEVER IS CHECKED OUT on the local bench, and report the result as the fleet's
# state. A bench is a workspace, not a mirror: on the founder's bench 51 of 53
# registry members sit on parked feature branches, so the gates grade a fleet that
# exists on no remote — and they are wrong in BOTH directions at once (a checkout
# behind its release under-records; one carrying an unmerged walk branch
# over-records pins that were never shipped). The observed harm is the remediation
# text: "walk it forward + verify" against a correct pin means DOWNGRADING it to
# match a stale working directory, so the gate's own instruction is the regression.
#
# This is the paper's cheapest ask, and the one it said would have saved the day on
# its own: name every consulted checkout that is not at its origin default, and say
# the verdict may not describe any remote. Purely additive — EXIT CODES ARE
# UNCHANGED. It converts a silent false RED into a self-labelling one.
#
# The second half SHIPPED in 0.25.1 — `_origin_view` materialises each member at
# `origin/<default>` and `registry --check` grades THAT, with `--source=worktree`
# as the deliberate local preview. Its trigger ("the next mgf-fed feature wave")
# fired early: 0.25.0 carried the new `registry-consistency` audit check to the
# fleet, that check consumes this same working-tree read, and it reddened every
# mgf-standard pipeline. So this banner is no longer the only defence — but it
# remains the INFORMATIONAL layer, and the split matters: bench divergence is
# context (a banner, on stderr), never a finding.
# ---------------------------------------------------------------------------

#: How many diverged checkouts the banner names before it tails a suppressed count.
#: Env-overridable so an operator triaging a wholly-parked bench can see everything.
_DIVERGENCE_LIMIT_ENV = "MGF_FED_DIVERGENCE_LIMIT"
_DIVERGENCE_LIMIT_DEFAULT = 8

#: Set to a truthy value to suppress the banner entirely (a bench that KNOWS it is
#: previewing local state). Silence is opt-in; it is never the default.
_DIVERGENCE_OFF_ENV = "MGF_FED_NO_DIVERGENCE_BANNER"


@dataclass(frozen=True, slots=True)
class CheckoutDivergence:
    """One consulted checkout that is not at its origin default branch."""

    repo: str
    #: The checked-out branch, or ``"(detached HEAD)"``.
    branch: str
    #: The ref compared against, e.g. ``"origin/main"``.
    default_ref: str
    ahead: int  # commits on HEAD that the default ref does not have
    behind: int  # commits on the default ref that HEAD does not have

    def __str__(self) -> str:
        return (
            f"{self.repo} — on {self.branch}, "
            f"ahead {self.ahead} / behind {self.behind} of {self.default_ref}"
        )


def _git(repo_dir: Path, *args: str) -> str | None:
    """Run a read-only git command in ``repo_dir``; ``None`` on any failure.

    Deliberately total: this feeds a purely advisory banner, so a repo git cannot
    answer for is simply not reported. A diagnostic must never be the thing that
    breaks the tool it is diagnosing.
    """
    try:
        p = subprocess.run(  # noqa: S603 — fixed argv, no shell, read-only git verbs
            ["git", "-C", str(repo_dir), *args],  # noqa: S607
            capture_output=True,
            text=True,
            timeout=15,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    return p.stdout.strip() if p.returncode == 0 else None


def _default_ref(repo_dir: Path) -> str | None:
    """The member's origin default branch, as a remote-tracking ref name.

    `refs/remotes/origin/HEAD` is authoritative when the clone has it; the
    main/master probe is the fallback for clones made without `--set-head`.
    """
    head = _git(repo_dir, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
    if head:
        return head
    for guess in ("origin/main", "origin/master"):
        if _git(repo_dir, "rev-parse", "--verify", "--quiet", guess):
            return guess
    return None


def divergent_checkouts(repo_dirs: Iterable[Path]) -> list[CheckoutDivergence]:
    """Which of the consulted checkouts are NOT at their origin default branch.

    Offline: compares against the last-fetched `refs/remotes/origin/*`, and never
    fetches. A directory that is not a git repo, has no origin default ref, or that
    git cannot answer for is skipped rather than guessed at — a banner that
    speculates would be the same class of defect it exists to label.
    """
    rows: list[CheckoutDivergence] = []
    for d in repo_dirs:
        if not is_git_repo(d):
            continue
        ref = _default_ref(d)
        if ref is None:
            continue
        counts = _git(d, "rev-list", "--left-right", "--count", f"{ref}...HEAD")
        if not counts:
            continue
        try:
            behind_s, ahead_s = counts.split()
            behind, ahead = int(behind_s), int(ahead_s)
        except ValueError:
            continue
        if not behind and not ahead:
            continue
        branch = _git(d, "symbolic-ref", "--quiet", "--short", "HEAD") or "(detached HEAD)"
        rows.append(
            CheckoutDivergence(
                repo=d.name, branch=branch, default_ref=ref, ahead=ahead, behind=behind
            )
        )
    rows.sort(key=lambda r: r.repo)
    return rows


def divergence_banner(rows: list[CheckoutDivergence], consulted: int, *, subject: str) -> str:
    """Render the loud banner for ``rows``; ``""`` when there is nothing to say."""
    if not rows:
        return ""
    try:
        limit = max(1, int(os.environ.get(_DIVERGENCE_LIMIT_ENV, _DIVERGENCE_LIMIT_DEFAULT)))
    except ValueError:
        limit = _DIVERGENCE_LIMIT_DEFAULT
    lines = [
        "",
        "⚠️  DIVERGENT CHECKOUTS — this verdict may describe no remote (PAPER-152).",
        f"    {len(rows)} of {consulted} consulted checkout(s) are NOT at their origin"
        " default branch.",
        f"    {subject} reads the working trees on THIS bench: a checkout behind its",
        "    release under-records, and one carrying an unmerged branch over-records.",
        "    Treat any finding naming these members as unproven, and do NOT 'walk",
        "    forward' a pin to match a stale working directory.",
    ]
    lines += [f"      - {row}" for row in rows[:limit]]
    if len(rows) > limit:
        lines.append(
            f"      … and {len(rows) - limit} more (set {_DIVERGENCE_LIMIT_ENV}=<n> to list them)"
        )
    lines += [
        "    Counts are against the last-fetched origin refs; no fetch was performed.",
        "",
    ]
    return "\n".join(lines)


def print_divergence_banner(repo_dirs: Iterable[Path], *, subject: str) -> str:
    """Compute + print the banner to STDERR. Returns what was printed ("" when clean).

    Never raises and never changes an exit code — the caller's verdict is its own.

    **Why stderr, corrected in 0.25.1.** 0.25.0 printed this to stdout, reasoning
    that a self-labelling verdict needs its label travelling beside it. That was
    wrong for a concrete reason: the stream this shares with the verdict is
    MACHINE-PARSED. `_audit.check_registry_consistency` captures `run_registry`'s
    stdout and turns every line matching `- <text>` into a Finding — and the
    banner's member list is exactly that shape. So on the founder's bench the
    banner's 8 names (its own cap) became 8 audit findings, and a diagnostic about
    the bench was reported as defects of the fleet, reddening every mgf-standard
    pipeline that installed 0.25.0.

    A diagnostic must not share a channel AND a shape with the data it annotates.
    stderr is the correct channel for a note about the tool's own inputs: still
    loud, still adjacent in any terminal or CI log, and structurally incapable of
    being read as a verdict line.
    """
    if os.environ.get(_DIVERGENCE_OFF_ENV):
        return ""
    try:
        # The denominator counts what was actually CONSULTABLE — `registry` passes
        # every directory under the federation root, and a non-repo among them would
        # otherwise inflate "N of M" into a number that describes nothing.
        dirs = [d for d in repo_dirs if is_git_repo(d)]
        banner = divergence_banner(divergent_checkouts(dirs), len(dirs), subject=subject)
    except Exception:  # a diagnostic must never break the tool it diagnoses
        return ""
    if banner:
        print(banner, file=sys.stderr, flush=True)
    return banner
