"""A read-only mirror of the federation at each member's ``origin/<default>``.

PAPER-152's **resolution half**. The banner (0.25.0, the paper's first offer) only
*labels* the problem: the gates still grade whatever is checked out on the local
bench. This materialises the answer they should have been reading all along — every
member's committed top-level files as of its origin default branch — into a
throwaway tree that the mgf-standard registry generator can be pointed at unchanged.

Why a MIRROR rather than teaching the generator to read refs
------------------------------------------------------------
The working-tree read lives in ``mgf-standard/scripts/gen_mgf_libraries.py``
(``discover()`` walks ``federation_root.iterdir()``), which is another repo's file.
FB-05 makes this repo read-only on it, and mgf-standard's own pipeline is the one
currently red — a unilateral cross-repo edit is the last thing this incident needs.
What mgf-fed *does* own is the invocation: it chooses the root the generator walks.
So the fix is to hand it a truthful root.

Why EVERY top-level file, and not the files the generator needs
---------------------------------------------------------------
Mirroring a known list (``pyproject.toml``, ``README.md``, ``CHANGELOG.md``, …)
would couple this module to the generator's private notion of what it reads — a
second two-map lockstep across two independently-released repos, which is precisely
the defect class 0.25.0 fixed in ``_catch_up._scaffold_dest`` (mgf-standard
PAPER-148). A file added to the generator's appetite would silently be absent here
and the gate would grade an incomplete tree while looking like it worked.

So the mirror copies **every top-level blob** at the ref, discovered with
``git ls-tree`` and read with ``git show``. It never needs to know what the
generator wants. Every file the generator reads per member is repo-root-level
(the manifests, ``README.md``, ``CHANGELOG.md``); ``mgf-standard`` additionally
gets its ``scripts/`` directory, because that is where the generator itself lives.

Honesty rules (both non-negotiable, and both fixture-pinned)
-------------------------------------------------------------
1. A member that cannot be resolved at its origin default — no remote-tracking
   ref, no git binary, a git that declines to answer — **falls back to its working
   tree and is NAMED**. Never silently. A verdict assembled partly from unshipped
   state while claiming to describe the remote is the exact failure this paper is
   about, and it would be worse coming from the fix for it.
2. Nothing here mutates the operator's repositories. See ``fetch`` below.
"""

from __future__ import annotations

import shutil
import subprocess
from dataclasses import dataclass, field
from pathlib import Path

from mgf.fed._roster import _default_ref, _git

#: Directories that are never federation members (the generator skips them too).
_NON_REPO_PREFIXES = (".", "_")

#: Subdirectories mirrored in full for specific repos, because the generator's own
#: code lives there. Keyed by repo name; everything else gets top-level blobs only.
_EXTRA_TREES = {"mgf-standard": ("scripts",)}


@dataclass(frozen=True, slots=True)
class OriginView:
    """The materialised mirror, and an honest account of how truthful it is."""

    root: Path
    #: Repos read at their origin default branch — the trustworthy majority.
    resolved: tuple[str, ...] = ()
    #: ``(repo, why)`` for every repo that FELL BACK to its working tree. A
    #: non-empty tuple means the verdict is partly bench state and must say so.
    degraded: tuple[tuple[str, str], ...] = ()
    #: Repos whose origin ref was refreshed before reading (empty unless fetching).
    fetched: tuple[str, ...] = field(default=())

    @property
    def fully_truthful(self) -> bool:
        return not self.degraded

    def degradation_banner(self) -> str:
        """The loud notice for a partially-degraded view; ``""`` when clean."""
        if not self.degraded:
            return ""
        lines = [
            "",
            "⚠️  ORIGIN-DEFAULT VIEW INCOMPLETE — part of this verdict is BENCH state.",
            f"    {len(self.degraded)} of {len(self.degraded) + len(self.resolved)} member(s)"
            " could not be read at their origin default",
            "    branch, so their WORKING TREE was used instead. Any finding naming one",
            "    of these may describe state that exists on no remote (PAPER-152).",
        ]
        lines += [f"      - {repo} — {why}" for repo, why in self.degraded]
        lines += [
            "    Re-run with a fetched clone (or `--source=origin` after `git fetch`)",
            "    to make the verdict whole.",
            "",
        ]
        return "\n".join(lines)


def _git_bytes(repo_dir: Path, *args: str) -> bytes | None:
    """A git read returning RAW BYTES; ``None`` on any failure.

    `_roster._git` strips its output — right for parsing `rev-list` counts, wrong
    for file content. `check_currency` compares mgf-standard's committed
    `MGF_LIBRARIES.md` byte-for-byte against a fresh render, so a mirror that
    stripped a trailing newline would manufacture a false STALE — the fix inventing
    the very false verdict it exists to remove.
    """
    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,
            timeout=30,
        )
    except (OSError, subprocess.SubprocessError):
        return None
    return p.stdout if p.returncode == 0 else None


def _is_candidate(d: Path) -> bool:
    return d.is_dir() and not d.name.startswith(_NON_REPO_PREFIXES) and (d / ".git").exists()


def _top_level_blobs(repo_dir: Path, ref: str) -> list[str] | None:
    """Names of the top-level FILES committed at ``ref`` — ``None`` if git can't say.

    ONE `ls-tree` call per repo: the long form already carries the object type, so
    directories are filtered without a `cat-file` round-trip per entry (which would
    be ~1200 subprocesses across this fleet).
    """
    raw = _git_bytes(repo_dir, "ls-tree", "-z", ref)
    if raw is None:
        return None
    names: list[str] = []
    for entry in raw.decode("utf-8", "replace").split("\0"):
        if not entry:
            continue
        meta, _, name = entry.partition("\t")
        parts = meta.split()
        if len(parts) >= 2 and parts[1] == "blob" and name:
            names.append(name)
    return names


def _copy_worktree_toplevel(src: Path, dst: Path) -> None:
    for p in src.iterdir():
        if p.is_file():
            try:
                shutil.copy2(p, dst / p.name)
            except OSError:
                continue


def _mirror_repo(repo_dir: Path, dst: Path, *, fetch: bool) -> tuple[str | None, str | None]:
    """Materialise one repo into ``dst``.

    Returns ``(resolved_ref, degradation_reason)`` — exactly one is non-None.
    """
    dst.mkdir(parents=True, exist_ok=True)
    # A planted marker so the mirror reads as a roster member. It is not a clone.
    #
    # IT MUST BE A DIRECTORY, and that is not mgf-fed's choice. `mkdir` here, rather
    # than `touch`, is load-bearing: this mirror's consumer is **mgf-standard's**
    # `scripts/gen_mgf_libraries.py`, which still tests `(repo_dir / ".git").is_dir()`.
    # Measured with the real generator — with a FILE marker, `discover()` sees one
    # member where a DIR marker shows two. Make this a file and you silently shrink
    # the registry.
    #
    # An earlier version of this comment said the requirement was gone because
    # PAPER-144 moved mgf-fed's own predicates onto existence. That was FALSE, and a
    # fresh-context chair caught it: PAPER-144 fixed *this* repo, while PAPER-163 —
    # "the roster test requires `.git` to be a DIRECTORY, and two independent
    # workarounds now plant fake markers to pass it" — remains OPEN in mgf-standard.
    # The workaround below is one of the two fake markers that paper names. It stays
    # until the generator stops requiring a directory; then this becomes a `touch`.
    (dst / ".git").mkdir(exist_ok=True)

    if shutil.which("git") is None:
        _copy_worktree_toplevel(repo_dir, dst)
        return None, "git is not on PATH"
    ref = _default_ref(repo_dir)
    if ref is None:
        _copy_worktree_toplevel(repo_dir, dst)
        return None, "no origin default branch (unfetched clone, or no `origin` remote)"
    if fetch:
        # Opt-in only — see `materialize`. A failed fetch is not fatal: the
        # last-fetched ref is still a better answer than the working tree.
        _git(repo_dir, "fetch", "--quiet", "origin")
        ref = _default_ref(repo_dir) or ref
    names = _top_level_blobs(repo_dir, ref)
    if names is None:
        _copy_worktree_toplevel(repo_dir, dst)
        return None, f"git could not read the tree at {ref}"
    for name in names:
        blob = _git_bytes(repo_dir, "show", f"{ref}:{name}")
        if blob is None:
            continue
        (dst / name).write_bytes(blob)  # byte-exact — see `_git_bytes`
    for sub in _EXTRA_TREES.get(repo_dir.name, ()):
        src_sub = repo_dir / sub
        if src_sub.is_dir():
            # The generator's own code — taken from the working tree deliberately:
            # it is the code being RUN, not data being graded, and running a
            # different generator than the operator has installed would make the
            # verdict unreproducible for them.
            shutil.copytree(src_sub, dst / sub, dirs_exist_ok=True)
    return ref, None


def materialize(root: Path, dest: Path, *, fetch: bool = False) -> OriginView:
    """Build the origin-default mirror of ``root`` under ``dest``.

    **Fetch policy — deliberately opt-in, and this is the honest reasoning.**
    ``registry-consistency`` is registered in ``_audit.CHECKS`` as *offline-capable*
    (``needs_network=False``). Fetching by default would quietly make a gate depend
    on the network, which is the property that declaration promises it does not
    have — and it would MUTATE the operator's repositories (moving their
    remote-tracking refs) from inside a read-only audit, which no check should do.
    In CI the point is moot: the workspace is a fresh clone whose origin refs are
    current by construction, which is the environment the gate actually grades in.

    So the default reads the last-fetched ``origin/<default>``, and the banner says
    so in as many words. ``fetch=True`` is the long-lived bench's opt-in before a
    release. Either way, a member that cannot be resolved is named, never assumed.
    """
    dest.mkdir(parents=True, exist_ok=True)
    resolved: list[str] = []
    degraded: list[tuple[str, str]] = []
    fetched: list[str] = []
    for repo_dir in sorted(p for p in root.iterdir() if _is_candidate(p)):
        _ref, why = _mirror_repo(repo_dir, dest / repo_dir.name, fetch=fetch)
        if why is None:
            resolved.append(repo_dir.name)
            if fetch:
                fetched.append(repo_dir.name)
        else:
            degraded.append((repo_dir.name, why))
    return OriginView(
        root=dest,
        resolved=tuple(resolved),
        degraded=tuple(degraded),
        fetched=tuple(fetched),
    )


__all__ = ["OriginView", "materialize"]
