#!/usr/bin/env python3
"""ping_sooth_ci.py — the sooth canary (CIR-22): prove Sooth still builds.

The Sooth family runs on MGF-owned CI infrastructure with no code dependency and
five OPERATIONAL dependencies (the cross-family CI contract,
``docs/recipes/cross-family-ci-contract.md``): the Woodpecker server + org
secrets, devpi + ``$DEVPI_URL``, ``mgf-fedres``, the shared template patterns,
and ``mgf-cloud``/``mgf-cost-watch``. Nothing tested any of those from Sooth's
side when MGF changed them — and the class bit twice (the 2026-07 secret
event-scope re-scope killed sooth-brand's manual runs + mgf-cost-watch's cron;
the ``$DEVPI_URL`` rename needed by-hand coordination).

**Run this before declaring done ANY change to one of the five surfaces** (an
org secret add/rotate/**re-scope**, the Woodpecker server env, a devpi/index
change, or an ``mgf-fedres`` release). It is the cross-family analogue of
``ping_sibling_ci.py``.

It reads the designated canary set from ``sooth-standard/SOOTH_LIBRARIES.json``,
triggers each repo's pipeline on its default branch via the self-hosted Woodpecker
API (MGF_INFRA §2), then **POLLS Woodpecker** (never Codeberg) until each
pipeline reaches a terminal state, and **exits non-zero if any is red or
unreachable**. The default set exercises each distinct dependency path once:

* **soothfast**     — the kernel; the leanest clone + devpi + fedres build.
* **sooth-standard** — the dogfood repo (runs its own ``verify_ci.py``).
* **soothsmith**    — the sibling-clone secret path (``sibling_clone_token``).

Override the set with ``--repos`` (e.g. add the repo whose pattern you touched).

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) —
                    a 401 is a solvable sub-problem, never "blocked".

Usage:
  python scripts/ping_sooth_ci.py --dry-run   # list the canary set, call nothing
  python scripts/ping_sooth_ci.py             # trigger + poll each to terminal

Exit status: 0 if every canary pipeline reached SUCCESS (or --dry-run); non-zero
if any was red, unreachable, timed out, or could not be triggered. A surface
change's release/playbook step should treat a non-zero as "Sooth is broken by
this change, do not declare done", not swallow it.
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from collections.abc import Callable, Sequence

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

#: The designated canary set (CIR-22). Overridable via --repos, but this default
#: is the contract: kernel build + dogfood + the sibling-clone secret path.
DEFAULT_CANARY_SET = ("soothfast", "sooth-standard", "soothsmith")

#: A pipeline is terminal once it stops changing; only SUCCESS is green. "blocked"
#: (awaiting manual approval) is terminal-and-not-green: the canary cannot confirm
#: a build it is not allowed to run, so it reports it rather than hanging forever.
TERMINAL = frozenset({"success", "failure", "error", "killed", "declined", "skipped", "blocked"})
GREEN = "success"

#: Polling defaults. A full sooth build is well under 10 min (p90 ~402 s across the
#: estate); 20 min of headroom absorbs a queued wave without hanging indefinitely.
DEFAULT_POLL_INTERVAL = 15.0
DEFAULT_TIMEOUT = 1200.0


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


def _default_sooth_root() -> Path:
    """Sibling checkout: ``<federation-root>/sooth-standard`` beside mgf-standard."""
    return _repo_root().parent / "sooth-standard"


def _load_registry(sooth_root: Path) -> dict:
    """Read SOOTH_LIBRARIES.json from the sooth-standard checkout (or --sooth-root)."""
    path = sooth_root / "SOOTH_LIBRARIES.json"
    if not path.is_file():
        sys.exit(
            f"ping_sooth_ci: cannot find {path} — pass --sooth-root pointing at the "
            "sooth-standard checkout (default: a sibling of mgf-standard)."
        )
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except (OSError, json.JSONDecodeError) as exc:  # a corrupt registry is a hard stop
        sys.exit(f"ping_sooth_ci: cannot read {path} ({exc})")


def _registry_names(registry: dict) -> set[str]:
    """Every repo identifier the registry knows (by ``name`` and by ``slug``)."""
    names: set[str] = set()
    for repo in registry.get("repos", []):
        for key in ("name", "slug"):
            val = repo.get(key)
            if val:
                names.add(str(val))
    return names


def resolve_canary_set(registry: dict, requested: Sequence[str]) -> list[str]:
    """Validate the requested canary repos against the registry, preserving order.

    A canary repo that is not a registered sooth family member is a configuration
    error, not something to silently skip — pinging a non-member (or a typo) would
    canary nothing. Fails loudly, naming the unknowns and what IS available.
    """
    known = _registry_names(registry)
    missing = [r for r in requested if r not in known]
    if missing:
        sys.exit(
            "ping_sooth_ci: canary repo(s) not in SOOTH_LIBRARIES.json: "
            f"{', '.join(missing)}. Known members: {', '.join(sorted(known))}."
        )
    return list(requested)


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 {}


@dataclass
class Result:
    repo: str
    ok: bool
    detail: str

    def line(self) -> str:
        tag = "OK  " if self.ok else "FAIL"
        return f"{tag}  {self.repo}: {self.detail}"


# The API callable is injectable (mirrors pr_wave's injected ``post``) so tests
# drive the whole trigger+poll loop with no network.
def _trigger(
    base: str,
    owner: str,
    repo: str,
    branch: str,
    token: str,
    api: Callable[..., dict],
) -> tuple[int | None, int | None, str]:
    """Look up the repo id and POST a pipeline. Returns ``(repo_id, number, err)``.

    ``err`` is empty on success; on failure ``repo_id``/``number`` are None and
    ``err`` carries the classified reason (mirrors ping_sibling_ci's handling —
    404 = not activated, 401 = token expired -> the MGF_INFRA §2 mint hint).
    """
    try:
        info = api(f"{base}/api/repos/lookup/{owner}/{repo}", token)
    except urllib.error.HTTPError as e:
        if e.code == 404:
            return None, None, "not activated on Woodpecker (404)"
        if e.code == 401:
            return None, None, "401 — token expired; mint per MGF_INFRA §2"
        return None, None, f"lookup HTTP {e.code}"
    except urllib.error.URLError as e:
        return None, None, f"lookup unreachable ({e.reason})"
    repo_id = info.get("id")
    if not repo_id:
        return None, None, "lookup returned no id"
    # An empty ``branch`` means each repo's own default branch (the lookup record
    # carries it) — soothfast/soothsmith live on ``master``, sooth-standard on
    # ``main``, so a single hardcoded name can never trigger all three.
    use_branch = branch or info.get("default_branch") or DEFAULT_BRANCH
    try:
        pipe = api(f"{base}/api/repos/{repo_id}/pipelines", token, "POST", {"branch": use_branch})
    except urllib.error.HTTPError as e:
        return None, None, f"trigger HTTP {e.code}"
    except urllib.error.URLError as e:
        return None, None, f"trigger unreachable ({e.reason})"
    number = pipe.get("number")
    if not number:
        return repo_id, None, "trigger returned no pipeline number"
    return int(repo_id), int(number), ""


def _poll(
    base: str,
    repo_id: int,
    number: int,
    token: str,
    api: Callable[..., dict],
    *,
    sleep: Callable[[float], None] = time.sleep,
    now: Callable[[], float] = time.monotonic,
    poll_interval: float = DEFAULT_POLL_INTERVAL,
    timeout: float = DEFAULT_TIMEOUT,
) -> str:
    """Poll one pipeline to a terminal status. Returns the status (or a sentinel).

    Polls Woodpecker ONLY (never Codeberg). Returns the terminal Woodpecker status
    string, ``"timeout"`` if it never terminated in ``timeout`` seconds, or
    ``"unreachable: <reason>"`` if the API stopped answering.
    """
    deadline = now() + timeout
    while True:
        try:
            pipe = api(f"{base}/api/repos/{repo_id}/pipelines/{number}", token)
        except urllib.error.HTTPError as e:
            return f"unreachable: poll HTTP {e.code}"
        except urllib.error.URLError as e:
            return f"unreachable: {e.reason}"
        status = str(pipe.get("status", ""))
        if status in TERMINAL:
            return status
        if now() >= deadline:
            return "timeout"
        sleep(poll_interval)


def run_canary(
    base: str,
    owner: str,
    repos: Sequence[str],
    branch: str,
    token: str,
    *,
    api: Callable[..., dict] = _api,
    sleep: Callable[[float], None] = time.sleep,
    now: Callable[[], float] = time.monotonic,
    poll_interval: float = DEFAULT_POLL_INTERVAL,
    timeout: float = DEFAULT_TIMEOUT,
    on_event: Callable[[str], None] | None = None,
) -> list[Result]:
    """Trigger every canary repo, then poll each to terminal. Returns per-repo results.

    Triggers ALL repos first (so the pipelines run in parallel on the shared
    runner) and only then polls, so total wall time is one build, not the sum.
    """

    def emit(msg: str) -> None:
        if on_event is not None:
            on_event(msg)

    triggered: list[tuple[str, int, int]] = []
    results: list[Result] = []
    for repo in repos:
        repo_id, number, err = _trigger(base, owner, repo, branch, token, api)
        if err or repo_id is None or number is None:
            results.append(Result(repo, ok=False, detail=err or "trigger failed"))
            emit(f"trigger FAIL {repo}: {err}")
            continue
        triggered.append((repo, repo_id, number))
        emit(f"triggered {repo}: pipeline #{number} on {branch or 'its default branch'}")

    for repo, repo_id, number in triggered:
        status = _poll(
            base,
            repo_id,
            number,
            token,
            api,
            sleep=sleep,
            now=now,
            poll_interval=poll_interval,
            timeout=timeout,
        )
        ok = status == GREEN
        detail = f"pipeline #{number} -> {status}"
        results.append(Result(repo, ok=ok, detail=detail))
        emit(f"polled {repo}: {status}")

    # Preserve the requested order in the returned list.
    order = {repo: i for i, repo in enumerate(repos)}
    results.sort(key=lambda r: order.get(r.repo, len(order)))
    return results


def main(argv: Sequence[str] | None = None) -> int:
    ap = argparse.ArgumentParser(
        description="The sooth canary (CIR-22): trigger + poll designated sooth pipelines green.",
    )
    ap.add_argument("--dry-run", action="store_true", help="list the canary set, trigger nothing")
    ap.add_argument("--owner", default=DEFAULT_OWNER, help=f"forge owner (default {DEFAULT_OWNER})")
    ap.add_argument(
        "--branch", default="", help="branch to build (default: each repo's default branch)"
    )
    ap.add_argument(
        "--repos",
        nargs="+",
        default=None,
        help=f"override the canary set (default: {' '.join(DEFAULT_CANARY_SET)})",
    )
    ap.add_argument(
        "--sooth-root",
        type=Path,
        default=None,
        help="path to the sooth-standard checkout (default: a sibling of mgf-standard)",
    )
    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)")
    ap.add_argument(
        "--poll-interval", type=float, default=DEFAULT_POLL_INTERVAL, help="seconds between polls"
    )
    ap.add_argument(
        "--timeout", type=float, default=DEFAULT_TIMEOUT, help="per-pipeline poll timeout (seconds)"
    )
    args = ap.parse_args(argv)

    sooth_root = args.sooth_root or _default_sooth_root()
    registry = _load_registry(sooth_root)
    requested = args.repos if args.repos is not None else list(DEFAULT_CANARY_SET)
    canary = resolve_canary_set(registry, requested)

    print(f"sooth canary (CIR-22) — SOOTH_LIBRARIES.json schema {registry.get('schema', '?')}")
    print(f"  canary set ({len(canary)}): {', '.join(canary)}\n")

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

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

    results = run_canary(
        args.url,
        args.owner,
        canary,
        args.branch,
        token,
        poll_interval=args.poll_interval,
        timeout=args.timeout,
        on_event=lambda m: print(f"  · {m}"),
    )
    print()
    for r in results:
        print(f"  {r.line()}")
    failed = [r for r in results if not r.ok]
    print(f"\n{len(results) - len(failed)}/{len(results)} green; {len(failed)} not green.")
    return 1 if failed else 0


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