#!/usr/bin/env python3
"""Compute + print the federation release **waves** (dependency-ordered).

A *wave* is a set of ``mgf-*`` repos with no unshipped mgf dependency among
them. Wave 0 is the roots (nothing mgf depends on them at runtime); wave *N*
holds every repo all of whose runtime mgf deps already shipped in waves < *N*.
Walking **roots → leaves** is the invariant order for any federation-wide
update — a cornerstone bump, a standards-corpus change, a cohort pin-walk: a
repo is eligible to ship only once every mgf dep *below* it is published + live.

Two edge kinds are deliberately NOT treated as ordering constraints, because
they would otherwise close cycles that do not reflect the *build* order:

  - ``mgf-test-supervisor`` — a DEV/TEST dependency. Everyone tests against it,
    yet it runtime-depends on ``mgf-common``; treating that as a build edge
    closes a cycle. TS-23 / CFM-04 carve-out.
  - ``mgf-standard`` — the standards/registry corpus, consumed by
    ``mgf-common`` as an OPTIONAL ``[standards]`` extra, not a runtime dep
    (ADR-026).

Both still appear in the waves as repos to ship; they are simply not treated as
*constraints*. ``mgf-standard`` is additionally special-cased in the update
runbook — ship its standards **first**, regenerate + publish its registry
**last** — see ``docs/recipes/federation-update-strategy.md``.

Reads the generated registry ``MGF_LIBRARIES.json`` so the order is always
derived from the live roster, never hand-maintained. Stdlib-only; the wave
algorithm (:func:`compute_waves`) is imported by ``gen_mgf_libraries.py`` so the
registry and this CLI never disagree.

Usage::

    python scripts/fed_waves.py            # print the waves (human-readable)
    python scripts/fed_waves.py --json     # emit {"waves": [[...], ...]}
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Iterable, Mapping

# Edges that are NOT runtime build-order constraints (see the module docstring).
DEV_TEST_EDGES = frozenset({"mgf-test-supervisor", "mgf-standard"})


def compute_waves(
    deps_by_repo: Mapping[str, Iterable[str]],
    *,
    dev_test_edges: frozenset[str] = DEV_TEST_EDGES,
) -> list[list[str]]:
    """Group repos into dependency waves, roots first.

    ``deps_by_repo`` maps each repo to an iterable of its ``mgf-*`` dependency
    repo names. Returns ``list[list[str]]`` — each wave sorted, the waves
    ordered roots → leaves. Dependencies outside the roster, self-deps, and any
    name in ``dev_test_edges`` are ignored as ordering constraints. Raises
    :class:`ValueError` naming the members of any genuine runtime cycle (a real
    layering violation, which the caller wants surfaced loudly, not papered
    over).
    """
    repos = set(deps_by_repo)
    pending: dict[str, set[str]] = {
        repo: {
            dep
            for dep in deps_by_repo[repo]
            if dep in repos and dep != repo and dep not in dev_test_edges
        }
        for repo in repos
    }
    placed: set[str] = set()
    waves: list[list[str]] = []
    while pending:
        ready = sorted(repo for repo, deps in pending.items() if deps <= placed)
        if not ready:
            raise ValueError("runtime dependency cycle among: " + ", ".join(sorted(pending)))
        waves.append(ready)
        placed.update(ready)
        for repo in ready:
            del pending[repo]
    return waves


def deps_by_repo_from_registry(registry: Mapping[str, object]) -> dict[str, list[str]]:
    """Extract the ``{repo: [mgf-* dep, ...]}`` graph from a parsed registry."""
    libraries = registry["libraries"]
    assert isinstance(libraries, list)
    out: dict[str, list[str]] = {}
    for lib in libraries:
        assert isinstance(lib, dict)
        deps = lib.get("deps") or {}
        out[lib["repo"]] = [d for d in deps if str(d).startswith("mgf-")]
    return out


def waves_from_registry(json_path: Path) -> list[list[str]]:
    """Read ``MGF_LIBRARIES.json`` and return its release waves."""
    data = json.loads(json_path.read_text(encoding="utf-8"))
    return compute_waves(deps_by_repo_from_registry(data))


def _default_registry() -> Path:
    return Path(__file__).resolve().parent.parent / "MGF_LIBRARIES.json"


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description="Print the federation release waves (dependency-ordered).",
    )
    parser.add_argument(
        "--registry",
        type=Path,
        default=_default_registry(),
        help="path to MGF_LIBRARIES.json (default: the repo-root registry)",
    )
    parser.add_argument(
        "--json",
        action="store_true",
        help='emit {"waves": [[repo, ...], ...]} instead of the human view',
    )
    args = parser.parse_args(argv)

    if not args.registry.is_file():
        print(
            f"error: registry not found: {args.registry} — run "
            "`python scripts/gen_mgf_libraries.py` first",
            file=sys.stderr,
        )
        return 2
    try:
        waves = waves_from_registry(args.registry)
    except ValueError as exc:
        print(f"error: {exc}", file=sys.stderr)
        return 1

    if args.json:
        print(json.dumps({"waves": waves}, indent=2))
        return 0

    total = sum(len(w) for w in waves)
    print(
        f"Federation release waves ({total} libraries, {len(waves)} waves) — ship roots → leaves:"
    )
    for i, wave in enumerate(waves):
        print(f"\nWAVE {i}")
        for repo in wave:
            print(f"  {repo}")
    print(
        "\nWithin a wave: no inter-deps — parallelizable. Between waves: a hard "
        "barrier (the lower wave must be published + REST-200 live first)."
    )
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
