"""``mgf-fed ping-siblings`` — AP-16 B-lite cornerstone fan-out.

After a mgf-common cornerstone MINOR release, trigger every mgf-common-consuming
sibling's CI so its compat-matrix (AP-16 / API_DESIGN §4.4) runs against the new
cornerstone — surfacing any break BEFORE a consumer pulls the untested combo. This
is the forcing function behind the wide ``mgf-common>=<floor>,<1.0`` pin: the wide
window is honest only because a cornerstone bump fans out to every sibling here.

Runs from mgf-common's release pipeline (tag event, post-publish). Resolves the
sibling list from the REL-13 registry — the federation-root roster when present
(dev), else the installed ``mgf.standard`` package data (single-repo CI, where
mgf-common[standards] ships ``_libraries.json``). Keeps the Python mgf-common
consumers (skips the cornerstone itself + non-Python + non-consumers — reported,
never silently dropped) and POSTs a pipeline trigger to each on the self-hosted
Woodpecker (MGF_INFRA §2), paced ``--stagger`` seconds apart so the forge's
per-build clone-credential minting stays under its rate limit (a tight burst
exhausts it and the tail's clone step fails "could not read Username").

Environment:
  WOODPECKER_URL    Woodpecker base URL (default the MGF_INFRA host; the CI wiring
                    passes the server's own ``$CI_SYSTEM_URL``).
  WOODPECKER_TOKEN  admin/user API token (JWT — expires; mint per MGF_INFRA §2). If
                    unset, falls back to mgf-standard/my_stuff/woodpecker_token.txt.

Exit status: 0 if every targeted sibling was triggered (or --dry-run); non-zero if
any lookup/trigger failed. A non-zero from the release pipeline is advisory (the
release already shipped) and means "fan-out incomplete — re-run ping-siblings".
"""

from __future__ import annotations

import json
import os
import time
import urllib.error
from typing import TYPE_CHECKING, Any

from mgf.fed._http import request_json
from mgf.fed._roster import federation_root, members

if TYPE_CHECKING:
    import argparse

_DEFAULT_WP_URL = "https://195.15.241.48.sslip.io"
_DEFAULT_OWNER = "magogi-admin"
_CORNERSTONE = "mgf-common"
# Seconds between triggers. A tight N-in-a-row burst makes Woodpecker mint N
# per-build clone credentials against the forge back-to-back, which exhausts
# Codeberg's rate limit after ~a dozen: the tail's `clone` step then fails with
# "could not read Username for 'https://codeberg.org'" and never runs the
# compat-matrix. Pacing the fan-out keeps credential minting under the limit.
# (Observed live on the mgf-common 0.49.2 fan-out: first 11 cloned, 12-27 failed.)
_DEFAULT_STAGGER = 4.0


def add_ping_siblings_args(parser: argparse.ArgumentParser) -> None:
    """Register the ``ping-siblings`` subcommand's arguments."""
    parser.add_argument(
        "--dry-run", action="store_true", help="list targets + skips, trigger nothing"
    )
    parser.add_argument(
        "--owner", default=_DEFAULT_OWNER, help=f"forge owner (default {_DEFAULT_OWNER})"
    )
    parser.add_argument("--branch", default="main", help="branch to build (default main)")
    parser.add_argument(
        "--url",
        default=os.environ.get("WOODPECKER_URL", _DEFAULT_WP_URL),
        help="Woodpecker base URL",
    )
    parser.add_argument(
        "--token", default=None, help="WP API token (else $WOODPECKER_TOKEN / my_stuff file)"
    )
    parser.add_argument(
        "--stagger",
        type=float,
        default=_DEFAULT_STAGGER,
        help=(
            f"seconds between triggers (default {_DEFAULT_STAGGER}); paces the fan-out "
            "so per-build clone-credential minting stays under the forge rate limit "
            "(0 = fire all at once, the pre-fix behaviour)"
        ),
    )


def _package_registry() -> dict[str, Any]:
    """The registry from the installed mgf.standard package data (single-repo CI,
    where mgf-common[standards] ships ``_libraries.json`` — no checkout on disk)."""
    from importlib.resources import files

    raw = files("mgf.standard").joinpath("_libraries.json").read_text(encoding="utf-8")
    data: dict[str, Any] = json.loads(raw)
    return data


def _consumers() -> tuple[list[str], list[tuple[str, str]]]:
    """(ping targets, [(repo, skip-reason)]) — the Python mgf-common consumers.

    Reads the federation-root roster when present (dev / federation-root), else the
    installed mgf.standard package data (single-repo CI). AP-16 fan-out runs in
    mgf-common's release CI, which has no sibling mgf-standard checkout.
    """
    entries: list[tuple[str, str, set[str]]] = []  # (repo, lang, dep-names)
    try:
        for m in members():
            entries.append((m.repo, m.lang, set(m.deps)))
    except FileNotFoundError:
        for lib in _package_registry().get("libraries", []):
            entries.append(
                (
                    str(lib["repo"]),
                    str(lib.get("lang", "other")),
                    set((lib.get("deps") or {}).keys()),
                )
            )

    ping: list[str] = []
    skipped: list[tuple[str, str]] = []
    for repo, lang, deps in entries:
        if repo == _CORNERSTONE:
            skipped.append((repo, "cornerstone (self)"))
        elif lang != "python":
            skipped.append((repo, f"lang={lang} (no pip compat-matrix)"))
        elif _CORNERSTONE not in deps:
            skipped.append((repo, "does not depend on mgf-common"))
        else:
            ping.append(repo)
    return ping, skipped


def _token(explicit: str | None) -> str | None:
    if explicit:
        return explicit
    if os.environ.get("WOODPECKER_TOKEN"):
        return os.environ["WOODPECKER_TOKEN"]
    stored = federation_root() / "mgf-standard" / "my_stuff" / "woodpecker_token.txt"
    if stored.is_file():
        return stored.read_text(encoding="utf-8").strip()
    return None


def _api(
    url: str, token: str, method: str = "GET", payload: dict[str, Any] | None = None
) -> dict[str, Any]:
    # Routed through the shared PAPER-119 helper: bounded exponential backoff +
    # jitter on HTTP 429 with a hard stop — never retry-hammer a rate wall.
    result: dict[str, Any] = request_json(
        url, method=method, token=token, payload=payload, timeout=30
    )
    return result


def _trigger(base: str, owner: str, repo: str, branch: str, token: str) -> str:
    """Look up the Woodpecker repo id and POST a pipeline. Returns a status line."""
    try:
        info = _api(f"{base}/api/repos/lookup/{owner}/{repo}", token)
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return f"SKIP  {repo}: not activated on Woodpecker (404)"
        if e.code == 401:
            return f"AUTH  {repo}: 401 — token expired; mint per MGF_INFRA §2"
        return f"FAIL  {repo}: lookup HTTP {e.code}"
    except urllib.error.URLError as e:
        return f"FAIL  {repo}: lookup unreachable ({e.reason})"
    repo_id = info.get("id")
    if not repo_id:
        return f"FAIL  {repo}: lookup returned no id"
    try:
        pipe = _api(f"{base}/api/repos/{repo_id}/pipelines", token, "POST", {"branch": branch})
    except urllib.error.HTTPError as e:
        return f"FAIL  {repo}: trigger HTTP {e.code}"
    except urllib.error.URLError as e:
        return f"FAIL  {repo}: trigger unreachable ({e.reason})"
    return f"OK    {repo}: pipeline #{pipe.get('number', '?')} on {branch}"


def run_ping_siblings(args: argparse.Namespace) -> int:
    """Trigger every mgf-common-consuming sibling's CI (AP-16 B-lite fan-out)."""
    ping, skipped = _consumers()
    print(f"AP-16 sibling ping — {len(ping)} to trigger; {len(skipped)} skipped")
    for repo, why in skipped:
        print(f"  skip  {repo}: {why}")

    if getattr(args, "dry_run", False):
        for repo in ping:
            print(f"  would trigger  {repo}  ({args.url}, branch {args.branch})")
        print(f"[dry-run] {len(ping)} sibling(s) would be pinged; nothing called.")
        return 0

    token = _token(getattr(args, "token", None))
    if not token:
        print(
            "ping-siblings: no token — set $WOODPECKER_TOKEN or "
            "mgf-standard/my_stuff/woodpecker_token.txt (mint per MGF_INFRA §2)."
        )
        return 2

    stagger = getattr(args, "stagger", _DEFAULT_STAGGER)
    if stagger:
        print(f"  (pacing {len(ping)} triggers {stagger}s apart — forge rate-limit guard)")
    results: list[str] = []
    for i, repo in enumerate(ping):
        line = _trigger(args.url, args.owner, repo, args.branch, token)
        print(f"  {line}", flush=True)  # stream: the step is now ~len*stagger seconds long
        results.append(line)
        if stagger and i < len(ping) - 1:
            time.sleep(stagger)
    failed = [r for r in results if r.startswith(("FAIL", "AUTH"))]
    print(f"{len(results) - len(failed)}/{len(results)} triggered; {len(failed)} failed.")
    return 1 if failed else 0


__all__ = ["add_ping_siblings_args", "run_ping_siblings"]
