#!/usr/bin/env -S uv run --no-project --python >=3.12 python
"""scripts/main-ci-status — is main RED on its own post-merge CI run, and who owns it?

`scripts/ship --remote` gates every merge on GitHub's check.yml, so a merge
commit is green *at push time*. main can still go red afterwards: a sibling's
concurrent merge lands bytes the gated tree never saw (ruff-format drift is the
recurring case), and check.yml's `push: main` run on the merge commit fails.
Nothing watched that run (gr346534) — the red was discovered by whoever synced
next, and three sessions then fixed the same four files independently because
nothing said who owned it.

This script is the watcher + the ownership signal:

* reads the newest *completed, non-cancelled* `check` run on `main` via `gh`
  (cancelled = superseded by the concurrency group, not a verdict);
* on RED prints the sha, run id, failing jobs, and the `gh run view --log-failed`
  line to read — then scans every in-flight worktree's `.claude/purpose` for a
  claim (`red main` / `main red` / `ci red` / `fix main`, case-insensitive). A
  claim names the owner; no claim prints the exact line to write so the NEXT
  session sees it in `scripts/inflight` instead of duplicating the fix;
* on GREEN says so (one line), or nothing under `--for-hook`;
* on EITHER conclusion, refuses to announce a verdict at all when the `gh`
  listing looks stale — an old run on a sha main has left behind, with nothing
  newer listed. GitHub has served a weeks-old cached page from both the CLI and
  the REST endpoint; a stale red costs a wasted re-check, a stale green is read
  as licence to skip the gate, so that line prints even under `--for-hook`.

Modes:
  scripts/main-ci-status              print the verdict (green or red)
  scripts/main-ci-status --for-hook   SessionStart: print only when RED (silent
                                      on green / offline / no gh)

Also reports the nightly browser check of the se viewer (viewer-check.yml,
scripts/viewer_check.py) on its own line: RED and gone-quiet print even
under `--for-hook`, green only without it. A red there is not a red main —
it is a viewer control that stopped changing the picture; the run's
screenshots are the evidence (`gh run download <id>`).

Advisory: always exits 0. Offline or without `gh` auth it prints nothing under
`--for-hook` and a one-line "unavailable" otherwise.
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
from datetime import UTC, datetime
from pathlib import Path

CLAIM_RE = re.compile(r"\b(red main|main red|ci red|fix(?:ing)? main|main ci)\b", re.I)
ROOT = Path(__file__).resolve().parent.parent


def _gh(*args: str) -> str | None:
    try:
        cp = subprocess.run(
            ["gh", *args],
            capture_output=True,
            text=True,
            encoding="utf-8",
            timeout=30,
            check=False,
        )
    except (OSError, subprocess.TimeoutExpired):
        return None
    return cp.stdout if cp.returncode == 0 else None


def latest_main_run() -> tuple[dict | None, dict | None]:
    """(newest completed non-cancelled run, newest in-progress run) on main."""
    out = _gh(
        "run",
        "list",
        "--branch",
        "main",
        "--workflow",
        "check",
        "--event",
        "push",
        # 30, not 8: a merge burst cancels each superseded run, and on
        # 2026-10-10 the 8 newest were all cancelled or in progress, so the
        # last real verdict (a failure) fell off the page and read "unavailable".
        "--limit",
        "30",
        "--json",
        "databaseId,headSha,conclusion,status,createdAt",
    )
    if not out:
        return None, None
    # Sort ourselves: the list endpoint has returned a page with a 6-day-old
    # run first (2026-09-18, right after a 5-merge burst), which read as a
    # stale RED. Newest-first by createdAt, not by trust in the API's order.
    runs = sorted(json.loads(out), key=lambda r: r.get("createdAt", ""), reverse=True)
    verdict = next(
        (
            r
            for r in runs
            if r["status"] == "completed" and r["conclusion"] != "cancelled"
        ),
        None,
    )
    pending = next((r for r in runs if r["status"] != "completed"), None)
    return verdict, pending


def failing_jobs(run_id: int) -> list[str]:
    out = _gh("run", "view", str(run_id), "--json", "jobs")
    if not out:
        return []
    return [
        j["name"]
        for j in json.loads(out).get("jobs", [])
        if j.get("conclusion") == "failure"
    ]


def age_minutes(iso: str) -> int:
    then = datetime.fromisoformat(iso.replace("Z", "+00:00"))
    return int((datetime.now(UTC) - then).total_seconds() // 60)


def age(iso: str) -> str:
    mins = age_minutes(iso)
    return f"{mins}m ago" if mins < 120 else f"{mins // 60}h ago"


STALE_AFTER_MIN = 12 * 60


def main_head_sha() -> str | None:
    out = _gh("api", "repos/{owner}/{repo}/commits/main", "--jq", ".sha")
    return out.strip() if out else None


def is_stale_listing(verdict: dict) -> bool:
    """True when a verdict is old AND main has moved past it with no newer
    run listed — a stale API page, not a standing verdict.

    Conclusion-agnostic on purpose: the same cached page that once served a
    six-day-old failure can just as easily serve a stale success, and a
    stale success is the more expensive of the two to believe (gr456236).
    """
    if age_minutes(verdict["createdAt"]) < STALE_AFTER_MIN:
        return False
    head = main_head_sha()
    return bool(head) and not str(head).startswith(verdict["headSha"])


def stale_line(verdict: dict, pending: dict | None) -> str:
    """The one message both conclusions print when the listing is stale."""
    outcome = "success" if verdict["conclusion"] == "success" else "failure"
    return (
        "main-ci-status: gh listing looks stale — newest completed run is a "
        f"{age(verdict['createdAt'])} {outcome} on {verdict['headSha'][:8]}, "
        "not main's head"
        + (
            f"; a run is {pending['status']} on {pending['headSha'][:8]}"
            if pending
            else ""
        )
        + " — re-run scripts/main-ci-status before acting"
    )


def inflight_trees() -> list[dict]:
    try:
        cp = subprocess.run(
            [str(ROOT / "scripts" / "inflight"), "--json"],
            capture_output=True,
            text=True,
            encoding="utf-8",
            timeout=30,
            check=False,
        )
        return json.loads(cp.stdout).get("worktrees", []) if cp.returncode == 0 else []
    except (OSError, subprocess.TimeoutExpired, ValueError):
        return []


def claims(trees: list[dict]) -> list[tuple[str, str]]:
    """(worktree name, purpose line) for every tree whose purpose claims the red."""
    found: list[tuple[str, str]] = []
    for t in trees:
        p = Path(t.get("path", "")) / ".claude" / "purpose"
        try:
            line = p.read_text(encoding="utf-8").strip().splitlines()[0]
        except (OSError, IndexError):
            continue
        if CLAIM_RE.search(line):
            found.append((t.get("name", "?"), line))
    return found


def main() -> int:
    for_hook = "--for-hook" in sys.argv[1:]
    verdict, pending = latest_main_run()
    if verdict is None:
        if not for_hook:
            print(
                "main-ci-status: unavailable (gh offline/unauthenticated, or no "
                "non-cancelled completed run in the last 30)"
            )
        return 0
    sha = verdict["headSha"][:8]

    # Staleness is tested on BOTH conclusions, before either is announced.
    # A stale red costs one wasted re-check; a stale green is read as licence
    # to skip the gate entirely, since policy is to run this script before
    # any local gate — so the green path needed the guard most and was the
    # only one without it (gr456236).
    if is_stale_listing(verdict):
        # Printed even under --for-hook: the hook stays silent on green,
        # which is exactly where an unguarded stale green passes unremarked.
        print(stale_line(verdict, pending))
        return 0

    if verdict["conclusion"] == "success":
        if not for_hook:
            print(
                f"main-ci-status: ✓ main green on CI ({sha}, run {verdict['databaseId']}, {age(verdict['createdAt'])})"
            )
        return 0

    jobs = ", ".join(failing_jobs(verdict["databaseId"])) or "(jobs unavailable)"
    print(
        f"🔴 main is RED on CI: {sha} run {verdict['databaseId']} ({age(verdict['createdAt'])}) — "
        f"failing: {jobs}. Read: gh run view {verdict['databaseId']} --log-failed"
    )
    if pending:
        print(
            f"   a newer run is {pending['status']} on {pending['headSha'][:8]} — re-check before acting"
        )
    owners = claims(inflight_trees())
    if owners:
        for name, line in owners:
            print(f"   claimed by {name}: {line}")
        print(
            "   → not yours unless that tree is you; sync main after their ship instead of fixing it twice"
        )
    else:
        print(
            "   UNCLAIMED — before fixing, claim it so siblings don't duplicate the work "
            f"(gr346534):  echo 'fix red main {sha}: {jobs}' > .claude/purpose"
        )
    return 0


VIEWER_QUIET_AFTER_MIN = 36 * 60


def latest_viewer_run() -> dict | None:
    """Newest completed, non-cancelled viewer-check run on main."""
    out = _gh(
        "run",
        "list",
        "--branch",
        "main",
        "--workflow",
        "viewer-check",
        "--limit",
        "5",
        "--json",
        "databaseId,headSha,conclusion,status,createdAt",
    )
    if not out:
        return None
    runs = sorted(json.loads(out), key=lambda r: r.get("createdAt", ""), reverse=True)
    return next(
        (
            r
            for r in runs
            if r["status"] == "completed" and r["conclusion"] != "cancelled"
        ),
        None,
    )


def viewer_check_report() -> None:
    """One line on the nightly browser check, or none."""
    for_hook = "--for-hook" in sys.argv[1:]
    run = latest_viewer_run()
    if run is None:
        if not for_hook:
            print("viewer-check: no completed run yet (or gh unavailable)")
        return
    rid, sha, when = run["databaseId"], run["headSha"][:8], age(run["createdAt"])
    if run["conclusion"] != "success":
        print(
            f"🔴 viewer-check RED: run {rid} on {sha} ({when}) — a se viewer control "
            f"stopped changing the picture. Screenshots + report.json: gh run download {rid}. "
            "Diagnose first (a browser check can flake), then file one gripe with the cause."
        )
        return
    if age_minutes(run["createdAt"]) > VIEWER_QUIET_AFTER_MIN:
        print(
            f"viewer-check: last run {when} (run {rid}, green) — the nightly has gone quiet; "
            "check the workflow is still scheduled"
        )
        return
    if not for_hook:
        print(f"viewer-check: ✓ green ({sha}, run {rid}, {when})")


if __name__ == "__main__":
    os.chdir(ROOT)
    rc = main()
    viewer_check_report()
    sys.exit(rc)
