#!/usr/bin/env python3
"""ping_sibling_ci.py — AP-16 B-lite: fan a cornerstone bump out to the siblings.

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's
matrix here. Reads the sibling list from the REL-13 registry (MGF_LIBRARIES.json),
keeps the Python siblings that depend on mgf-common (skipping the cornerstone itself
and any non-Python / non-activated repo — reported, never silently dropped), and
POSTs a pipeline trigger to each on the self-hosted Woodpecker (MGF_INFRA §2).

Environment:
  WOODPECKER_URL    Woodpecker base URL (default https://195.15.241.48.sslip.io).
  WOODPECKER_TOKEN  admin/user API token (JWT). If unset, falls back to
                    my_stuff/woodpecker_token.txt. The token EXPIRES (-> 401);
                    mint a fresh one per MGF_INFRA.md §2 (host-side, it's ours).

Usage:
  python scripts/ping_sibling_ci.py --dry-run    # list targets + skips, call nothing
  python scripts/ping_sibling_ci.py              # trigger every sibling's CI

Exit status: 0 if every targeted sibling was triggered (or --dry-run); non-zero if
any lookup/trigger failed. mgf-common's release pipeline should treat a non-zero as
"fan-out incomplete, investigate", not swallow it.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import urllib.error
import urllib.request
from pathlib import Path

DEFAULT_WP_URL = "https://195.15.241.48.sslip.io"
DEFAULT_OWNER = "magogi-admin"
DEFAULT_BRANCH = "main"
CORNERSTONE_DIST = "mgf-common"


def _repo_root() -> Path:
    """The mgf-standard checkout root (this script lives in ``<root>/scripts/``)."""
    return Path(__file__).resolve().parent.parent


def _load_registry() -> dict:
    """Read MGF_LIBRARIES.json from the checkout, falling back to package data."""
    local = _repo_root() / "MGF_LIBRARIES.json"
    if local.is_file():
        return json.loads(local.read_text(encoding="utf-8"))
    try:
        from importlib.resources import files

        data = files("mgf.standard").joinpath("_libraries.json").read_text(encoding="utf-8")
        return json.loads(data)
    except Exception as exc:  # a missing registry is a hard, explained stop
        sys.exit(f"ping_sibling_ci: cannot locate MGF_LIBRARIES.json ({exc})")


def _targets(registry: dict) -> tuple[list[str], list[tuple[str, str]]]:
    """Split the registry into (ping targets, [(repo, skip-reason)])."""
    ping: list[str] = []
    skipped: list[tuple[str, str]] = []
    for lib in registry.get("libraries", []):
        repo = lib.get("repo", "")
        if repo == CORNERSTONE_DIST:
            skipped.append((repo, "cornerstone (self)"))
        elif lib.get("lang") != "python":
            skipped.append((repo, f"lang={lib.get('lang')!r} (no pip compat-matrix)"))
        elif CORNERSTONE_DIST not in (lib.get("deps") or {}):
            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 = _repo_root() / "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 | None = None) -> dict:
    data = json.dumps(payload).encode() if payload is not None else None
    req = urllib.request.Request(
        url,
        data=data,
        method=method,
        headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
    )
    with urllib.request.urlopen(req, timeout=30) as resp:  # fixed https host
        body = resp.read().decode()
        return json.loads(body) if body.strip() else {}


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 main(argv: list[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        description="AP-16 B-lite: fan a cornerstone bump out to every sibling's compat-matrix CI.",
    )
    ap.add_argument("--dry-run", action="store_true", help="list targets + skips, trigger nothing")
    ap.add_argument("--owner", default=DEFAULT_OWNER, help=f"forge owner (default {DEFAULT_OWNER})")
    ap.add_argument("--branch", default=DEFAULT_BRANCH, help=f"branch to build (default {DEFAULT_BRANCH})")
    ap.add_argument(
        "--url",
        default=os.environ.get("WOODPECKER_URL", DEFAULT_WP_URL),
        help="Woodpecker base URL",
    )
    ap.add_argument("--token", default=None, help="WP API token (else env / my_stuff file)")
    args = ap.parse_args(argv)

    registry = _load_registry()
    ping, skipped = _targets(registry)
    print(
        f"AP-16 sibling ping — cornerstone {registry.get('cornerstone', 'mgf-common')} "
        f"{registry.get('cornerstone_version', '?')}"
    )
    print(f"  {len(ping)} sibling(s) to trigger; {len(skipped)} skipped\n")
    for repo, why in skipped:
        print(f"  skip  {repo}: {why}")
    if skipped:
        print()

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

    token = _token(args.token)
    if not token:
        sys.exit(
            "ping_sibling_ci: no token — set $WOODPECKER_TOKEN or "
            "my_stuff/woodpecker_token.txt (mint per MGF_INFRA §2)."
        )

    results = [_trigger(args.url, args.owner, repo, args.branch, token) for repo in ping]
    for line in results:
        print(f"  {line}")
    failed = [r for r in results if r.startswith(("FAIL", "AUTH"))]
    print(f"\n{len(results) - len(failed)}/{len(results)} triggered; {len(failed)} failed.")
    return 1 if failed else 0


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