#!/usr/bin/env python3
"""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`.

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)

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",
        "--limit",
        "8",
        "--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 red verdict is old AND main has moved past it with no
    newer run listed — a stale API page, not a standing red."""
    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 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 completed run)"
            )
        return 0
    sha = verdict["headSha"][:8]
    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

    if is_stale_listing(verdict):
        # A red verdict >12h old on a sha that is no longer main's head, with
        # nothing newer listed: the page was stale (seen once), not a red main.
        print(
            f"main-ci-status: gh listing looks stale — newest completed run is a "
            f"{age(verdict['createdAt'])} failure on {sha}, 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"
        )
        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


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