#!/usr/bin/env python3
"""
Generate docs/ci/workflow-status.md — a live dashboard of every GitHub Actions
workflow in this repo, grouped by role, with disabled / manual-only / stale
items surfaced at the top.

Usage:
    pdm run python scripts/generate_workflow_status.py                # Generate
    pdm run python scripts/generate_workflow_status.py --output PATH  # Custom path
    pdm run python scripts/generate_workflow_status.py --check-structure
                                                                      # CI mode: exit 1
                                                                      # if any workflow
                                                                      # file is missing
                                                                      # a row in the
                                                                      # current dashboard
    pdm run python scripts/generate_workflow_status.py --verbose      # Log API calls

Why two modes:
  Live data (last_run timestamps, badge status) cannot be byte-equality
  --check'd against committed output — every regeneration changes the
  timestamps. Instead, --check-structure does a fast, deterministic check
  that every .github/workflows/*.yml file has a row in the dashboard.
  Live regeneration is on demand.

GitHub API:
  Uses the `gh` CLI. Requires `gh auth login` with `repo, workflow` scopes.
  Owner/repo is read at runtime from `gh repo view`.

API cost:
  ~1 call per workflow (62) + caller-resolution (~30 extra) ≈ 100 calls per
  full run. Well within the 5000/hr authenticated rate limit.
"""

import argparse
import json
import re
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional

import yaml

REPO_ROOT = Path(__file__).resolve().parent.parent
WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows"
DEFAULT_OUTPUT = REPO_ROOT / "docs" / "ci" / "workflow-status.md"

BEGIN_MARKER = "<!-- BEGIN GENERATED -->"
END_MARKER = "<!-- END GENERATED -->"

NEW_FILE_DAYS = 14
STALE_MULTIPLIER = 2  # last_success > N * cron_period_days → stale
RATE_LIMIT_WARN = 200
RATE_LIMIT_ABORT = 50

GATE_ROOTS = {"release.yml", "release-gate.yml", "ci-gate.yml"}

# Job names recognized as the release-gate "summary"-shaped roll-up that
# nests other jobs. We list candidates by the named role they fill so we
# don't depend on the exact YAML key.
RELEASE_GATE_FILE = "release-gate.yml"
RELEASE_FILE = "release.yml"
CI_GATE_FILE = "ci-gate.yml"


# ============================================================================
# gh CLI helpers
# ============================================================================


def run_gh(args: list[str], verbose: bool = False) -> str:
    """Run `gh <args...>` and return stdout. Raises on nonzero exit."""
    if verbose:
        print(f"  gh {' '.join(args)}", file=sys.stderr)
    result = subprocess.run(
        ["gh", *args], capture_output=True, text=True, check=False
    )
    if result.returncode != 0:
        raise RuntimeError(
            f"gh {' '.join(args)} failed (exit {result.returncode}):\n"
            f"{result.stderr.strip()}"
        )
    return result.stdout


def gh_json(args: list[str], verbose: bool = False) -> Any:
    """Run `gh` returning a single JSON value (object/array). Use
    `gh_json_stream` for `--jq` streamed output."""
    out = run_gh(args, verbose=verbose)
    return json.loads(out) if out.strip() else None


def gh_json_stream(args: list[str], verbose: bool = False) -> list[Any]:
    """Run `gh api ... --jq` whose output is a stream of JSON objects
    (one per line, NDJSON). Returns a list."""
    out = run_gh(args, verbose=verbose).strip()
    if not out:
        return []
    return [json.loads(line) for line in out.splitlines() if line.strip()]


def preflight(verbose: bool = False) -> tuple[str, str]:
    """Verify gh auth and rate limits; return (owner, repo)."""
    # Auth check.
    auth = subprocess.run(
        ["gh", "auth", "status"], capture_output=True, text=True, check=False
    )
    if auth.returncode != 0:
        print(
            "FAIL: `gh` is not authenticated. Run `gh auth login` and retry.",
            file=sys.stderr,
        )
        sys.exit(2)

    # Rate limit check.
    rl = gh_json(
        ["api", "rate_limit", "--jq", ".resources.core"], verbose=verbose
    )
    remaining = rl["remaining"]
    limit = rl["limit"]
    if remaining < RATE_LIMIT_ABORT:
        print(
            f"FAIL: gh API rate limit too low: {remaining}/{limit}. "
            f"Wait until the reset window before regenerating.",
            file=sys.stderr,
        )
        sys.exit(2)
    if remaining < RATE_LIMIT_WARN:
        print(
            f"WARN: gh API rate limit low: {remaining}/{limit}. "
            f"Continuing but consider waiting for reset.",
            file=sys.stderr,
        )

    # Repo slug.
    repo = gh_json(["repo", "view", "--json", "owner,name"], verbose=verbose)
    return repo["owner"]["login"], repo["name"]


# ============================================================================
# Workflow YAML parsing
# ============================================================================


USES_LINE_RE = re.compile(
    r"""^(?P<indent>\s*)
        (?P<comment>\#\s*)?
        uses:\s*(?P<target>\.\/\.github\/workflows\/[A-Za-z0-9_.-]+\.ya?ml)\s*$
    """,
    re.VERBOSE,
)
JOB_KEY_RE = re.compile(r"^  (?:#\s*)?(?P<key>[A-Za-z0-9_-]+):\s*$")


def parse_workflow(path: Path) -> dict[str, Any]:
    """Extract the structured info we need from one workflow file.

    Returns:
        {
          "file": "release-gate.yml",
          "path": Path(...),
          "name": "Release Gate",
          "on": {...},                # raw dict from the `on:` block
          "uses_calls": [              # parsed line-by-line so we keep
            {                          # comment status (ground truth) AND
              "job_key": "nuclei-scan",  # job key context
              "callee": "nuclei.yml",
              "commented": True,
              "line": 177,
            }, ...
          ],
        }
    """
    text = path.read_text(encoding="utf-8")
    # Use yaml.safe_load on the file but with FullLoader behavior: the
    # `on:` key gets coerced to True (boolean) by safe_load when YAML 1.1
    # legacy boolean parsing kicks in. The simpler workaround is to load
    # then look for both 'on' and True.
    data = yaml.safe_load(text)
    if data is None:
        raise ValueError(f"{path.name}: empty YAML")

    on_block = data.get("on", data.get(True, {}))
    name = data.get("name", path.stem)

    uses_calls = _parse_uses_lines(text)

    return {
        "file": path.name,
        "path": path,
        "name": name,
        "on": on_block if isinstance(on_block, dict) else {on_block: None},
        "raw_text": text,
        "uses_calls": uses_calls,
    }


def _parse_uses_lines(text: str) -> list[dict[str, Any]]:
    """Walk lines once to capture (job_key, callee, commented?, line_no).

    Job context: track the most recent top-level `<key>:` under the
    `jobs:` mapping. Lines inside a step's `uses:` (indented further than
    a job key) get the most recent job key as context.
    """
    calls: list[dict[str, Any]] = []
    in_jobs = False
    current_job: Optional[str] = None
    for i, line in enumerate(text.splitlines(), start=1):
        # Track entering/leaving the top-level `jobs:` mapping.
        if re.match(r"^jobs:\s*$", line):
            in_jobs = True
            current_job = None
            continue
        if in_jobs:
            if line and not line.startswith(" ") and not line.startswith("#"):
                # New top-level key; out of `jobs:` block.
                in_jobs = False
                current_job = None
                continue
            m = JOB_KEY_RE.match(line)
            if m:
                current_job = m.group("key")

        m = USES_LINE_RE.match(line)
        if m:
            target = m.group("target")
            callee = target.rsplit("/", 1)[-1]
            calls.append(
                {
                    "job_key": current_job or "",
                    "callee": callee,
                    "commented": bool(m.group("comment")),
                    "line": i,
                }
            )
    return calls


# ============================================================================
# Call graph
# ============================================================================


def build_call_graph(
    workflows: dict[str, dict[str, Any]],
) -> tuple[dict[str, list[dict[str, Any]]], dict[str, set[str]]]:
    """Return (callers_of, transitive_root_callers).

    callers_of[file] = list of {caller, job_key, commented, line} dicts —
        every direct caller (including commented-out ones, which we use
        to detect "disabled").
    transitive_root_callers[file] = set of root-caller filenames; subset
        of GATE_ROOTS, plus possibly the file itself if it has no callers.
    """
    callers_of: dict[str, list[dict[str, Any]]] = {f: [] for f in workflows}

    for caller_file, wf in workflows.items():
        for call in wf["uses_calls"]:
            callee = call["callee"]
            if callee in callers_of:
                callers_of[callee].append(
                    {
                        "caller": caller_file,
                        "job_key": call["job_key"],
                        "commented": call["commented"],
                        "line": call["line"],
                    }
                )

    # BFS upward to find which roots ultimately invoke this workflow.
    transitive: dict[str, set[str]] = {}
    for f in workflows:
        roots: set[str] = set()
        seen: set[str] = set()
        stack = [f]
        while stack:
            cur = stack.pop()
            if cur in seen:
                continue
            seen.add(cur)
            active_callers = [c for c in callers_of[cur] if not c["commented"]]
            if not active_callers:
                if cur in GATE_ROOTS:
                    roots.add(cur)
                continue
            for c in active_callers:
                if c["caller"] in GATE_ROOTS:
                    roots.add(c["caller"])
                stack.append(c["caller"])
        transitive[f] = roots
    return callers_of, transitive


# ============================================================================
# GitHub run history
# ============================================================================


def fetch_workflow_meta(
    owner: str, repo: str, file: str, verbose: bool = False
) -> Optional[dict[str, Any]]:
    """Return {id, state, last_run, recent_runs}.

    Returns None if the workflow isn't registered with GitHub (e.g. very
    new file not yet picked up).
    """
    try:
        meta = gh_json(
            [
                "api",
                f"repos/{owner}/{repo}/actions/workflows/{file}",
            ],
            verbose=verbose,
        )
    except RuntimeError as e:
        if "404" in str(e) or "Not Found" in str(e):
            return None
        raise
    runs_list = gh_json_stream(
        [
            "api",
            f"repos/{owner}/{repo}/actions/workflows/{file}/runs?per_page=5",
            "--jq",
            ".workflow_runs[] | {created_at, conclusion}",
        ],
        verbose=verbose,
    )
    last = runs_list[0] if runs_list else None
    return {
        "id": meta["id"],
        "state": meta.get("state", "active"),
        "last_run": last["created_at"] if last else None,
        "recent_runs": runs_list,
    }


def fetch_last_gated_run(
    owner: str,
    repo: str,
    caller_file: str,
    job_keys: set[str],
    verbose: bool = False,
) -> Optional[dict[str, Any]]:
    """Find the most recent run of caller_file that included any of
    `job_keys` as a job, and return that job's status."""
    runs_list = gh_json_stream(
        [
            "api",
            f"repos/{owner}/{repo}/actions/workflows/{caller_file}/runs?per_page=5",
            "--jq",
            ".workflow_runs[] | {created_at, event, conclusion, status, id}",
        ],
        verbose=verbose,
    )
    latest_match: Optional[dict[str, Any]] = None
    latest_success: Optional[dict[str, Any]] = None
    for run in runs_list:
        run_id = run["id"]
        try:
            jobs = gh_json_stream(
                [
                    "api",
                    f"repos/{owner}/{repo}/actions/runs/{run_id}/jobs"
                    "?per_page=100",
                    "--jq",
                    ".jobs[] | {name, conclusion, status, completed_at}",
                ],
                verbose=verbose,
            )
        except RuntimeError:
            continue
        # Reusable workflows show as `<job_key> / <inner-job-name>` in the
        # caller run's jobs list. Match prefix against any of our keys.
        for job in jobs:
            jname = job["name"]
            head = jname.split(" / ", 1)[0]
            if head not in job_keys:
                continue
            entry = {
                "caller": caller_file,
                "caller_run_id": run_id,
                "job_name": jname,
                "conclusion": job["conclusion"],
                "status": job["status"],
                "completed_at": job["completed_at"] or run["created_at"],
                "run_created": run["created_at"],
            }
            if latest_match is None:
                latest_match = entry
            if latest_success is None and job["conclusion"] == "success":
                latest_success = entry
            if latest_match and latest_success:
                break
        if latest_match and latest_success:
            break
    if latest_match is None:
        return None
    # Stash the most-recent-success entry alongside the activity entry
    # so the caller can use whichever it needs without a second API
    # round-trip. `latest_success` is `None` if none of the inspected
    # caller runs included a successful invocation of this workflow.
    latest_match["latest_success"] = latest_success
    return latest_match


# ============================================================================
# Classification
# ============================================================================


def file_added_at(path: Path) -> Optional[str]:
    """ISO date of the commit that introduced this file, or None."""
    try:
        out = subprocess.run(
            [
                "git",
                "log",
                "--diff-filter=A",
                "--format=%aI",
                "--",
                str(path),
            ],
            capture_output=True,
            text=True,
            check=False,
            cwd=REPO_ROOT,
        )
        if out.returncode != 0:
            return None
        lines = [
            ln.strip() for ln in out.stdout.strip().splitlines() if ln.strip()
        ]
        return lines[-1] if lines else None
    except Exception:
        return None


def cron_cadence_days(cron: str) -> Optional[float]:
    """Best-effort: convert a cron string to its rough cadence in days.

    Recognises:
      `* * * * *`  → 1/1440 day (1 min); we cap at 1 hour minimum
      `M H * * *`  → 1 day
      `M H * * D`  → 7 days
      `M H D * *`  → 30 days
      `M H D M *`  → 365 days
    """
    parts = cron.strip().split()
    if len(parts) != 5:
        return None
    minute, hour, dom, month, dow = parts
    if month != "*":
        return 365.0
    if dom != "*":
        return 30.0
    if dow != "*":
        return 7.0
    if hour != "*":
        return 1.0
    if minute != "*":
        return 1.0 / 24
    return 1.0 / 1440


def get_schedules(on_block: dict) -> list[str]:
    """Extract cron strings from an on: block."""
    sched = on_block.get("schedule")
    if not sched:
        return []
    out = []
    if isinstance(sched, list):
        for entry in sched:
            if isinstance(entry, dict) and "cron" in entry:
                out.append(entry["cron"])
    return out


def trigger_summary(on_block: dict) -> str:
    """Compact human-readable trigger description."""
    if not on_block:
        return "—"
    parts: list[str] = []
    keys = list(on_block.keys())
    for k in keys:
        if k == "schedule":
            crons = get_schedules(on_block)
            if crons:
                parts.append(f"schedule({', '.join(crons)})")
        elif k == "pull_request":
            parts.append("PR")
        elif k == "pull_request_target":
            parts.append("PR-target")
        elif k == "push":
            v = on_block[k]
            if isinstance(v, dict) and "branches" in v:
                parts.append(f"push:{','.join(v['branches'])}")
            else:
                parts.append("push")
        elif k == "workflow_call":
            parts.append("workflow_call")
        elif k == "workflow_dispatch":
            parts.append("manual")
        elif k == "repository_dispatch":
            parts.append("repo_dispatch")
        else:
            parts.append(str(k))
    return ", ".join(parts)


def primary_role(
    workflow_file: str,
    on_block: dict,
    callers: list[dict[str, Any]],
    transitive_roots: set[str],
) -> str:
    """Bucket every workflow into exactly one display group.

    Order matters: workflow can satisfy multiple, but lands in the first
    matching group.
    """
    active_callers = [c for c in callers if not c["commented"]]

    # Disabled supersedes everything else.
    if callers and not active_callers:
        return "disabled"

    # Manual-only by design.
    only_dispatch = set(on_block.keys()) - {True} == {
        "workflow_dispatch"
    } or list(on_block.keys()) == ["workflow_dispatch"]
    if only_dispatch and not active_callers:
        return "manual"

    # Gate buckets.
    if (
        RELEASE_GATE_FILE in transitive_roots
        and workflow_file != RELEASE_GATE_FILE
    ):
        # The release-gate.yml schedule is daily.
        return "gate-daily"
    if (
        RELEASE_FILE in transitive_roots
        and RELEASE_GATE_FILE not in transitive_roots
        and workflow_file not in {RELEASE_FILE, RELEASE_GATE_FILE}
    ):
        return "gate-release"
    if (
        CI_GATE_FILE in transitive_roots
        and RELEASE_GATE_FILE not in transitive_roots
        and workflow_file not in {CI_GATE_FILE, RELEASE_FILE, RELEASE_GATE_FILE}
    ):
        return "gate-release"

    # Direct trigger buckets.
    has_schedule = "schedule" in on_block
    has_pr = "pull_request" in on_block or "pull_request_target" in on_block
    has_push = "push" in on_block
    has_repo_dispatch = "repository_dispatch" in on_block

    if has_repo_dispatch and not has_pr and not has_push and not has_schedule:
        return "repo-dispatch"
    if has_schedule:
        return "schedule"
    if has_pr or has_push:
        return "pr-push"

    return "other"


def classify_staleness(
    workflow: dict[str, Any],
    callers: list[dict[str, Any]],
    last_success_iso: Optional[str],
    file_added_iso: Optional[str],
    now: datetime,
) -> Optional[str]:
    """Return 'disabled' / 'manual-only' / 'stale' / None.

    `last_success_iso` is the most recent successful run timestamp from
    any source (direct or gated), already computed by the caller across
    the recent_runs list — checking only the most-recent-run conclusion
    misses the case where last week failed but the week before passed.
    """
    on_block = workflow["on"]
    state = workflow.get("state", "active")
    active_callers = [c for c in callers if not c["commented"]]

    # 1. Disabled: caller commented OR GitHub UI state non-active.
    #    Treat "missing" as new (not yet registered with GitHub) — handled
    #    by the new-file check below instead of mis-flagging as disabled.
    if callers and not active_callers:
        return "disabled"
    if state not in ("active", "missing"):
        return "disabled"

    # 2. Manual-only: workflow_dispatch only AND no caller.
    only_dispatch = list(on_block.keys()) == ["workflow_dispatch"]
    if only_dispatch and not active_callers:
        return "manual-only"

    # 3. New: skip stale check.
    if file_added_iso:
        added = datetime.fromisoformat(file_added_iso)
        if (now - added).days < NEW_FILE_DAYS:
            return None

    # 4. Stale: scheduled trigger AND no successful run within
    #    STALE_MULTIPLIER * cadence (or 60d, whichever larger).
    crons = get_schedules(on_block)
    if not crons:
        return None
    cadences = [c for c in (cron_cadence_days(x) for x in crons) if c]
    if not cadences:
        return None
    threshold_days = max(60.0, STALE_MULTIPLIER * min(cadences))

    if last_success_iso is None:
        return "stale"
    last_success = datetime.fromisoformat(
        last_success_iso.replace("Z", "+00:00")
    )
    if (now - last_success).days > threshold_days:
        return "stale"
    return None


# ============================================================================
# Markdown rendering
# ============================================================================


GROUP_ORDER = [
    ("disabled", "⚠ Disabled workflows"),
    ("stale", "⚠ Stale (scheduled but no recent successful run)"),
    ("manual", "ℹ Manual-only by design"),
    (
        "gate-daily",
        "Release-blocking gates — daily (release-gate cron 02:00 UTC)",
    ),
    ("gate-release", "Release gates — release-time only"),
    ("schedule", "Scheduled (own cron)"),
    ("pr-push", "PR / push checks"),
    ("repo-dispatch", "Repository-dispatch publishers"),
    ("other", "Other"),
]

# Coarse calendar-day buckets for "Last activity"-type columns. Goal:
# regenerations only produce a diff when a workflow drifts between
# buckets — exact timestamps would change every run and drown the signal.
# Boundaries are inclusive on the high side. The smallest bucket is
# 30 days so daily/weekly healthy workflows never wobble across
# bucket boundaries between regenerations.
ACTIVITY_BUCKETS = [
    (30, "last 30 days"),
    (90, "1-3 months ago"),
    (180, "3-6 months ago"),
]


def relative_bucket(iso: Optional[str], now: datetime) -> str:
    """Return a coarse bucket label, or 'never'.

    Uses calendar-day delta (no time-of-day jitter) so two regenerations
    on the same date produce identical labels."""
    if not iso:
        return "never"
    dt = datetime.fromisoformat(iso.replace("Z", "+00:00"))
    days = (now.date() - dt.date()).days
    for threshold, label in ACTIVITY_BUCKETS:
        if days <= threshold:
            return label
    return "long ago"


def badge_md(
    owner: str, repo: str, file: str, event_filter: Optional[str] = None
) -> str:
    """`[![badge](svg)](runs)` — live status, links to runs page.

    `event_filter` (e.g. "schedule") appends `?event=...` to the badge
    URL, which makes GitHub render the status of the most recent run
    *for that event* rather than the most recent run regardless of
    trigger. Useful for workflows that fire on multiple events (e.g.
    PR + cron) where we want the badge to reflect the cron health
    specifically. Verified effective by SHA-comparing badge bodies for
    workflows with multi-event run history.
    """
    badge = (
        f"https://github.com/{owner}/{repo}/actions/workflows/{file}/badge.svg"
    )
    runs = f"https://github.com/{owner}/{repo}/actions/workflows/{file}"
    if event_filter:
        badge = f"{badge}?event={event_filter}"
        runs = f"{runs}?query=event%3A{event_filter}"
    return f"[![status]({badge})]({runs})"


def render(
    workflows: list[dict[str, Any]],
    owner: str,
    repo: str,
    now: datetime,
) -> str:
    """Render the generated section. Caller wraps with markers."""
    lines: list[str] = []

    by_group: dict[str, list[dict[str, Any]]] = {k: [] for k, _ in GROUP_ORDER}
    for wf in workflows:
        by_group.setdefault(wf["primary_role"], []).append(wf)

    # Aggregated health banner. Counts only change when a workflow shifts
    # between {disabled, stale, manual, active}, so this line is as
    # diff-stable as the per-row buckets.
    total = len(workflows)
    n_disabled = len(by_group.get("disabled", []))
    n_stale = len(by_group.get("stale", []))
    n_manual = len(by_group.get("manual", []))
    n_active = total - n_disabled - n_stale - n_manual
    parts = [f"**{total} workflows:**"]
    if n_disabled:
        parts.append(f"{n_disabled} disabled")
    if n_stale:
        parts.append(f"{n_stale} stale")
    if n_manual:
        parts.append(f"{n_manual} manual-only")
    parts.append(f"{n_active} active")
    lines.append(" ".join(parts[:1]) + " " + " · ".join(parts[1:]))
    lines.append("")

    for key, heading in GROUP_ORDER:
        rows = by_group.get(key, [])
        lines.append(f"## {heading}")
        lines.append("")
        if not rows:
            lines.append("_None._")
            lines.append("")
            continue

        if key == "disabled":
            lines.append("| Workflow | Disabled where | Last direct run |")
            lines.append("|---|---|---|")
            for wf in sorted(rows, key=lambda w: w["file"]):
                where = (
                    "; ".join(
                        f"`{c['caller']}:{c['line']}` (commented)"
                        for c in wf["callers_of"]
                        if c["commented"]
                    )
                    or f"GitHub UI state = `{wf.get('state', '?')}`"
                )
                lines.append(
                    f"| `{wf['file']}` | {where} | "
                    f"{relative_bucket(wf['last_direct_run_iso'], now)} |"
                )
            lines.append("")
            continue

        if key == "manual":
            lines.append("| Workflow | Last manual run | Trigger |")
            lines.append("|---|---|---|")
            for wf in sorted(rows, key=lambda w: w["file"]):
                lines.append(
                    f"| `{wf['file']}` | "
                    f"{relative_bucket(wf['last_direct_run_iso'], now)} | "
                    f"{trigger_summary(wf['on'])} |"
                )
            lines.append("")
            continue

        if key == "stale":
            lines.append("| Workflow | Cron | Last successful run |")
            lines.append("|---|---|---|")
            for wf in sorted(rows, key=lambda w: w["file"]):
                crons = ", ".join(get_schedules(wf["on"])) or "—"
                last_success = wf.get("last_success_iso")
                lines.append(
                    f"| `{wf['file']}` | `{crons}` | "
                    f"{relative_bucket(last_success, now)} |"
                )
            lines.append("")
            continue

        # Generic active groups.
        # For the "schedule" group (own cron, often combined with PR
        # triggers like gitleaks/fuzz/codeql), filter the badge to the
        # scheduled event so the rendered status reflects cron health,
        # not whichever PR ran last.
        event_filter = "schedule" if key == "schedule" else None
        lines.append("| Workflow | Last activity | Trigger | Live badge |")
        lines.append("|---|---|---|---|")
        for wf in sorted(rows, key=lambda w: w["file"]):
            primary_iso = _pick_activity(wf, key)
            lines.append(
                f"| `{wf['file']}` | "
                f"{relative_bucket(primary_iso, now)} | "
                f"{trigger_summary(wf['on'])} | "
                f"{badge_md(owner, repo, wf['file'], event_filter)} |"
            )
        lines.append("")

    return "\n".join(lines)


def _pick_activity(wf: dict[str, Any], group: str) -> Optional[str]:
    """Choose which run timestamp to display per group."""
    if group in ("gate-daily", "gate-release"):
        gated = wf.get("last_gated")
        if gated:
            return gated.get("completed_at") or gated.get("run_created")
    return wf["last_direct_run_iso"]


# ============================================================================
# File assembly with markers
# ============================================================================


PAGE_TEMPLATE = """\
# Workflow Status

> **Live status of every GitHub Actions workflow in this repo.**
> Auto-generated by [`scripts/generate_workflow_status.py`](../../scripts/generate_workflow_status.py).
> Do not edit between the generated markers — regenerate with
> `pdm run python scripts/generate_workflow_status.py`. Anything outside
> the markers is preserved on regeneration.

## How to read this page

- **Live badges** (right column on active gates) re-render on every page
  view and reflect the current head-of-default-branch status from
  GitHub. Click one to land on that workflow's runs page. The badge is
  the source of truth for current status — there is intentionally no
  static status column, because that would flip every regeneration as
  the most-recent run cycles through `success → skipped → in_progress`.
- **Last activity** uses coarse calendar buckets — `last 30 days`,
  `1-3 months ago`, `3-6 months ago`, `long ago`, `never`. Exact dates
  would change every regeneration; buckets only change when a workflow
  drifts, which is the signal worth seeing in version-bump diffs.
- **Disabled** = a caller has the `uses:` line commented out, or the
  workflow is disabled in the GitHub UI. **Stale** = scheduled trigger
  but no successful run within 2× its cron cadence (and ≥60 days). The
  three top sections are the action items.
- Reusable workflows (those triggered only by `workflow_call:`) show
  their **gated** run — the most recent run of their parent (release.yml,
  release-gate.yml, ci-gate.yml) that included them — not their own
  empty direct-run history.

{begin_marker}

{generated}

{end_marker}

_Regenerate with `pdm run python scripts/generate_workflow_status.py`._
"""


def assemble(generated: str, now: datetime) -> str:
    # `now` accepted for API symmetry; no timestamp is rendered into
    # the file. Git history is authoritative for "when" — embedding a
    # date here would cause a single-line diff on every regeneration
    # even when nothing else drifted.
    del now
    return PAGE_TEMPLATE.format(
        begin_marker=BEGIN_MARKER,
        generated=generated.strip(),
        end_marker=END_MARKER,
    )


def merge_with_existing(new_full: str, existing_path: Path) -> str:
    """Preserve everything outside the marker pair. Inside the markers
    (including the timestamp line) is fully owned by the generator."""
    if not existing_path.exists():
        return new_full
    existing = existing_path.read_text(encoding="utf-8")
    if BEGIN_MARKER not in existing or END_MARKER not in existing:
        return new_full
    if existing.index(BEGIN_MARKER) > existing.index(END_MARKER):
        # Markers out of order (mid-merge-conflict, manual editing
        # mistake, etc.) — bail to a clean overwrite rather than try
        # to splice and produce interleaved garbage.
        return new_full
    new_inner = _between(new_full, BEGIN_MARKER, END_MARKER)
    head = existing.split(BEGIN_MARKER, 1)[0]
    tail = existing.split(END_MARKER, 1)[1]
    return f"{head}{BEGIN_MARKER}{new_inner}{END_MARKER}{tail}"


def _between(text: str, start: str, end: str) -> str:
    return text.split(start, 1)[1].split(end, 1)[0]


# ============================================================================
# Modes
# ============================================================================


def cmd_generate(output: Path, verbose: bool) -> int:
    print("Preflight: checking gh auth and rate limit…", file=sys.stderr)
    owner, repo = preflight(verbose=verbose)
    print(f"Target: {owner}/{repo}", file=sys.stderr)

    paths = sorted(WORKFLOWS_DIR.glob("*.yml"))
    if not paths:
        print(f"No workflows found in {WORKFLOWS_DIR}", file=sys.stderr)
        return 1
    print(f"Discovered {len(paths)} workflows", file=sys.stderr)

    workflows: dict[str, dict[str, Any]] = {}
    for p in paths:
        try:
            workflows[p.name] = parse_workflow(p)
        except Exception as e:
            print(f"WARN: failed to parse {p.name}: {e}", file=sys.stderr)

    callers_of, transitive_roots = build_call_graph(workflows)

    now = datetime.now(timezone.utc)

    # Pre-compute the (caller_file -> {job_keys for each callee}) map.
    job_keys_for: dict[tuple[str, str], set[str]] = {}
    for caller_file, wf in workflows.items():
        for call in wf["uses_calls"]:
            if call["commented"]:
                continue
            key = (caller_file, call["callee"])
            job_keys_for.setdefault(key, set()).add(call["job_key"])

    print("Fetching workflow metadata + runs…", file=sys.stderr)
    for i, (file, wf) in enumerate(workflows.items(), start=1):
        if verbose:
            print(f"[{i}/{len(workflows)}] {file}", file=sys.stderr)
        meta = fetch_workflow_meta(owner, repo, file, verbose=verbose)
        wf["state"] = meta["state"] if meta else "missing"
        wf["id"] = meta["id"] if meta else None
        wf["last_direct_run_iso"] = meta["last_run"] if meta else None
        wf["callers_of"] = callers_of[file]
        wf["transitive_roots"] = transitive_roots[file]

        # Resolve gated activity.
        wf["last_gated"] = None
        for caller_file in wf["transitive_roots"]:
            if caller_file == file:
                continue
            keys = job_keys_for.get((caller_file, file), set())
            if not keys:
                # Walk one level deeper: a transitive root reaches `file`
                # via some intermediate caller — collect any job keys for
                # `file` from any active caller, then check the root for
                # those keys.
                for intermediate in workflows:
                    keys |= job_keys_for.get((intermediate, file), set())
            if not keys:
                continue
            gated = fetch_last_gated_run(
                owner, repo, caller_file, keys, verbose=verbose
            )
            if gated:
                cur = wf["last_gated"]
                if cur is None or (gated["completed_at"] or "") > (
                    cur["completed_at"] or ""
                ):
                    wf["last_gated"] = gated

        # File-added timestamp for new-file exception.
        wf["file_added_iso"] = file_added_at(wf["path"])

        # Compute "last successful" by walking recent runs (not just the
        # most recent — a workflow that ran red yesterday and green a
        # week ago is not stale).
        last_success = None
        recent = (meta["recent_runs"] if meta else []) or []
        for r in recent:
            if r.get("conclusion") == "success":
                last_success = r["created_at"]
                break
        # Pull the most-recent-success gated entry (computed alongside
        # the latest match by `fetch_last_gated_run`). This is needed
        # because the *latest* gated run is often `in_progress` during
        # an active release, which would otherwise mask a real success
        # from the previous release and trip the stale-flag false.
        gated = wf["last_gated"] or {}
        gated_success = gated.get("latest_success") or (
            gated if gated.get("conclusion") == "success" else None
        )
        if gated_success:
            cand = gated_success.get("completed_at") or gated_success.get(
                "run_created"
            )
            if cand and (last_success is None or cand > last_success):
                last_success = cand
        wf["last_success_iso"] = last_success

        # Staleness verdict (None if active). Uses the success timestamp
        # we just computed, not the most-recent-run conclusion.
        wf["stale_verdict"] = classify_staleness(
            wf,
            callers_of[file],
            wf["last_success_iso"],
            wf["file_added_iso"],
            now,
        )

        # Primary role for grouping.
        if wf["stale_verdict"] == "disabled":
            wf["primary_role"] = "disabled"
        elif wf["stale_verdict"] == "manual-only":
            wf["primary_role"] = "manual"
        elif wf["stale_verdict"] == "stale":
            wf["primary_role"] = "stale"
        else:
            wf["primary_role"] = primary_role(
                file,
                wf["on"],
                callers_of[file],
                transitive_roots[file],
            )

    generated = render(list(workflows.values()), owner, repo, now)
    full = assemble(generated, now)

    output.parent.mkdir(parents=True, exist_ok=True)
    final = merge_with_existing(full, output)
    output.write_text(final, encoding="utf-8")

    counts = {k: 0 for k, _ in GROUP_ORDER}
    for wf in workflows.values():
        counts[wf["primary_role"]] = counts.get(wf["primary_role"], 0) + 1
    print(
        f"Wrote {output} ({len(workflows)} workflows: "
        + ", ".join(f"{n} {k}" for k, n in counts.items() if n)
        + ")"
    )
    return 0


def cmd_check_structure(output: Path) -> int:
    """Verify every workflow file has a row in the dashboard."""
    if not output.exists():
        print(
            f"FAIL: {output} does not exist. Generate it with "
            f"`pdm run python scripts/generate_workflow_status.py`.",
            file=sys.stderr,
        )
        return 1
    text = output.read_text(encoding="utf-8")
    missing = []
    for p in sorted(WORKFLOWS_DIR.glob("*.yml")):
        if f"`{p.name}`" not in text:
            missing.append(p.name)
    if missing:
        print(
            f"FAIL: {output} is missing rows for {len(missing)} workflow(s):",
            file=sys.stderr,
        )
        for m in missing:
            print(f"  - {m}", file=sys.stderr)
        print(
            "Regenerate with `pdm run python scripts/generate_workflow_status.py`.",
            file=sys.stderr,
        )
        return 1
    print(
        f"OK: {output} has rows for all {len(list(WORKFLOWS_DIR.glob('*.yml')))} workflows."
    )
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(
        description=(
            "Generate docs/ci/workflow-status.md from .github/workflows/*.yml."
        )
    )
    parser.add_argument(
        "--output",
        "-o",
        type=Path,
        default=DEFAULT_OUTPUT,
        help=f"Output path (default: {DEFAULT_OUTPUT.relative_to(REPO_ROOT)})",
    )
    parser.add_argument(
        "--check-structure",
        action="store_true",
        help=(
            "Verify every workflow file has a row in the dashboard. "
            "Exits 1 if any is missing. No API calls."
        ),
    )
    parser.add_argument(
        "--verbose",
        action="store_true",
        help="Print one line per gh API call.",
    )
    args = parser.parse_args()

    if args.check_structure:
        return cmd_check_structure(args.output)
    return cmd_generate(args.output, args.verbose)


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