#!/usr/bin/env python3
"""Collect CI job/step timings from the GitHub API and generate an HTML diff report.

In CI, the script reads GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, and
GITHUB_SHA from the environment to collect timings via the REST API.

If a baseline JSON file (ci-timings-baseline.json by default) exists, the
report includes a diff with per-job and per-step deltas, plus a gantt chart
overlaying current vs baseline bars.

Usage:
    # Collect from API (CI mode):
    python scripts/ci/timings_report.py

    # Regenerate HTML from saved JSON (testing):
    python scripts/ci/timings_report.py --from-json ci-timings.json
"""

from __future__ import annotations

import argparse
import json
import os
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import datetime
from html import escape

API_BASE = "https://api.github.com"

# Retry policy for GitHub API calls. The repo-scoped GITHUB_TOKEN shares a
# rate-limit budget across every concurrent workflow run; when several PRs
# run CI at once, this report job (which makes dozens of paginated calls)
# regularly hits 403 rate-limit responses. Those are transient — retry with
# backoff, honoring Retry-After / X-RateLimit-Reset when present.
_RETRY_STATUSES = {403, 429, 500, 502, 503, 504}
_MAX_ATTEMPTS = 5
_MAX_RETRY_WAIT_S = 120.0


class TimingsUnavailable(Exception):
    """GitHub API data could not be collected (rate limit, outage, ...).

    This is a REPORT job — never a reason to fail the PR's checks. main()
    catches this and exits 0 with a degraded summary.
    """


def _retry_wait_s(headers, attempt: int) -> float:
    """Seconds to wait before the next attempt, honoring server hints."""
    retry_after = (headers.get("Retry-After") or "").strip() if headers else ""
    if retry_after.isdigit():
        return min(float(retry_after), _MAX_RETRY_WAIT_S)
    reset = (headers.get("X-RateLimit-Reset") or "").strip() if headers else ""
    remaining = (headers.get("X-RateLimit-Remaining") or "").strip() if headers else ""
    if remaining == "0" and reset.isdigit():
        return min(max(float(reset) - time.time(), 1.0), _MAX_RETRY_WAIT_S)
    return min(2.0 ** attempt * 2.0, _MAX_RETRY_WAIT_S)  # 4s, 8s, 16s, 32s


def _urlopen_with_retry(req: urllib.request.Request):
    """urlopen with backoff on rate-limit/transient statuses.

    Returns (parsed_json, link_header). Raises TimingsUnavailable when
    attempts are exhausted — callers treat that as "no report this run",
    not a job failure.
    """
    last_err: Exception | None = None
    for attempt in range(1, _MAX_ATTEMPTS + 1):
        try:
            with urllib.request.urlopen(req) as resp:
                return json.loads(resp.read()), resp.headers.get("Link", "")
        except urllib.error.HTTPError as e:
            last_err = e
            if e.code not in _RETRY_STATUSES or attempt == _MAX_ATTEMPTS:
                break
            wait = _retry_wait_s(e.headers, attempt)
            print(f"GitHub API {e.code} on {req.full_url} — "
                  f"retry {attempt}/{_MAX_ATTEMPTS - 1} in {wait:.0f}s",
                  file=sys.stderr)
            time.sleep(wait)
        except urllib.error.URLError as e:
            last_err = e
            if attempt == _MAX_ATTEMPTS:
                break
            wait = _retry_wait_s(None, attempt)
            print(f"GitHub API connection error on {req.full_url} ({e.reason}) — "
                  f"retry {attempt}/{_MAX_ATTEMPTS - 1} in {wait:.0f}s",
                  file=sys.stderr)
            time.sleep(wait)
    raise TimingsUnavailable(
        f"GitHub API unavailable after {_MAX_ATTEMPTS} attempts: {last_err}"
    )


# ---------------------------------------------------------------------------
# GitHub API helpers
# ---------------------------------------------------------------------------

def api_get(path: str, token: str, params: dict | None = None,
            list_key: str | None = None) -> list | dict:
    """Authenticated GitHub API GET with automatic pagination.

    For list endpoints, pass list_key to extract items from the paginated
    wrapper response (e.g. list_key='jobs' for {'total_count': N, 'jobs': [...]}).
    When list_key is omitted, a non-list response is returned as-is (single object).

    Transient failures (403 rate limit, 429, 5xx, connection errors) are
    retried with backoff; exhausted retries raise TimingsUnavailable.
    """
    url = f"{API_BASE}{path}"
    if params:
        url += "?" + urllib.parse.urlencode(params)

    results: list = []
    while url:
        req = urllib.request.Request(url, headers={
            "Authorization": f"Bearer {token}",
            "Accept": "application/vnd.github+json",
            "X-GitHub-Api-Version": "2022-11-28",
            "User-Agent": "ci-timings-report",
        })
        data, link_header = _urlopen_with_retry(req)

        if list_key:
            results.extend(data.get(list_key, []))
        elif isinstance(data, list):
            results.extend(data)
        else:
            return data

        next_url = None
        for part in link_header.split(","):
            part = part.strip()
            if 'rel="next"' in part:
                next_url = part[part.find("<") + 1:part.find(">")]
                break
        url = next_url

    return results


def parse_ts(ts: str | None) -> datetime | None:
    if not ts:
        return None
    return datetime.fromisoformat(ts.replace("Z", "+00:00"))


def dur_s(started: str | None, completed: str | None) -> float | None:
    s = parse_ts(started)
    e = parse_ts(completed)
    if not s or not e:
        return None
    return (e - s).total_seconds()


def is_skipped(job: dict) -> bool:
    """A job is 'skipped' when GitHub didn't actually run it.

    Skipped jobs have conclusion == 'skipped' and typically have null or
    equal started_at/completed_at timestamps, yielding duration_s of None
    or 0. They should be excluded from delta comparisons, gantt bars,
    regression tables, and aggregate stats (wall/compute).
    """
    return job.get("conclusion") == "skipped"


# ---------------------------------------------------------------------------
# Timings collection
# ---------------------------------------------------------------------------

def _normalize_job(raw: dict) -> dict:
    steps = []
    for step in (raw.get("steps") or []):
        steps.append({
            "name": step.get("name", ""),
            "number": step.get("number", 0),
            "status": step.get("status", ""),
            "conclusion": step.get("conclusion", ""),
            "started_at": step.get("started_at"),
            "completed_at": step.get("completed_at"),
            "duration_s": dur_s(step.get("started_at"), step.get("completed_at")),
        })
    return {
        "name": raw.get("name", "unknown"),
        "workflow_name": raw.get("_workflow_name", ""),
        "job_id": raw.get("id"),
        "status": raw.get("status", ""),
        "conclusion": raw.get("conclusion", ""),
        "started_at": raw.get("started_at"),
        "completed_at": raw.get("completed_at"),
        "duration_s": dur_s(raw.get("started_at"), raw.get("completed_at")),
        "html_url": raw.get("html_url", ""),
        "steps": steps,
    }


def _annotate_wait_times(jobs: list[dict]) -> None:
    """Annotate each job with ``wait_s`` — how long it sat idle before starting.

    Wait time = ``started_at - max(completed_at of all jobs that finished
    before this job started)``. Jobs with no predecessor (e.g. ``detect``)
    get ``wait_s = 0``. Skipped jobs get ``wait_s = None``.

    This is a timestamp heuristic, not a workflow-YAML dependency parse: it
    infers dependencies from temporal ordering rather than ``needs:``
    declarations. It's accurate for pipeline-shaped CI where the critical
    path is linear at each stage (detect → parallel lanes → gate → report).
    """
    for j in jobs:
        if is_skipped(j):
            j["wait_s"] = None
            continue
        started = parse_ts(j.get("started_at"))
        if started is None:
            j["wait_s"] = None
            continue
        latest_dep_end: datetime | None = None
        for other in jobs:
            if other is j or is_skipped(other):
                continue
            other_end = parse_ts(other.get("completed_at"))
            if other_end is None or other_end > started:
                continue
            if latest_dep_end is None or other_end > latest_dep_end:
                latest_dep_end = other_end
        j["wait_s"] = (started - latest_dep_end).total_seconds() if latest_dep_end else 0.0


def collect_timings(token: str, repo: str, run_id: str, head_sha: str) -> dict:
    """Collect job/step timings from the GitHub API.

    1. Get orchestrator run's direct jobs (detect, all-checks-pass, etc.).
       Skip workflow-call placeholder jobs (step name starts with
       "Run ./.github/workflows/").
    2. Find sub-workflow runs via head_sha + event=workflow_call.
    3. Get each sub-workflow run's jobs with full step timing.
    """
    owner, repo_name = repo.split("/")

    # Orchestrator run info
    run_info = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{run_id}", token)
    created_at = run_info.get("created_at", "")

    # Orchestrator direct jobs
    orch_jobs = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{run_id}/jobs",
                        token, list_key="jobs")

    direct = []
    for job in orch_jobs:
        steps = job.get("steps") or []
        if any(s.get("name", "").startswith("Run ./.github/workflows/") for s in steps):
            continue  # workflow-call placeholder
        if job.get("status") in ("in_progress", "queued"):
            continue  # skip self / unfinished
        direct.append(job)

    # Sub-workflow runs
    sub_runs = api_get(f"/repos/{owner}/{repo_name}/actions/runs", token, params={
        "head_sha": head_sha,
        "event": "workflow_call",
        "per_page": 100,
    }, list_key="workflow_runs")
    sub_runs = [r for r in sub_runs if r.get("created_at", "") >= created_at]

    sub_jobs_raw = []
    for sr in sub_runs:
        sr_id = sr["id"]
        sr_name = sr.get("name", "")
        sr_jobs = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{sr_id}/jobs",
                          token, list_key="jobs")
        for j in sr_jobs:
            j["_workflow_name"] = sr_name
            j["_workflow_run_id"] = sr_id
            sub_jobs_raw.append(j)

    # Normalize + sort
    all_jobs = [_normalize_job(j) for j in direct + sub_jobs_raw]
    all_jobs = [j for j in all_jobs if j["status"] not in ("in_progress", "queued")]
    all_jobs.sort(key=lambda j: j.get("started_at") or "")
    _annotate_wait_times(all_jobs)

    return {
        "run_id": run_id,
        "head_sha": head_sha,
        "created_at": created_at,
        "jobs": all_jobs,
    }


# ---------------------------------------------------------------------------
# Formatting helpers
# ---------------------------------------------------------------------------

def fmt_dur(seconds: float | None) -> str:
    if seconds is None:
        return "—"
    if seconds < 60:
        return f"{seconds:.1f}s"
    m = int(seconds // 60)
    s = seconds % 60
    if s == 0:
        return f"{m}m"
    return f"{m}m{s:.0f}s"


def fmt_delta(current: float | None, baseline: float | None) -> tuple[str, str]:
    """Return (text, css_class) for a delta."""
    if current is None or baseline is None:
        return ("—", "neutral")
    delta = current - baseline
    if baseline == 0:
        pct_str = "new" if delta > 0 else "0%"
    else:
        pct = (delta / baseline) * 100
        pct_str = f"{pct:+.1f}%"
    if abs(delta) < 1.0:
        cls = "neutral"
    elif delta > 0:
        cls = "slower"
    else:
        cls = "faster"
    sign = "+" if delta >= 0 else ""
    return (f"{sign}{delta:.1f}s ({pct_str})", cls)


def nice_ticks(max_seconds: float, num_ticks: int = 8) -> list[int]:
    if max_seconds <= 0:
        return [0]
    raw = max_seconds / num_ticks
    for nice in [5, 10, 15, 30, 60, 120, 180, 300, 600, 900, 1800, 3600, 7200]:
        if nice >= raw:
            step = nice
            break
    else:
        step = max(int(raw), 3600)
    return list(range(0, int(max_seconds) + step + 1, step))


def fmt_tick(seconds: int) -> str:
    if seconds < 60:
        return f"{seconds}s"
    m, s = divmod(seconds, 60)
    if s == 0:
        return f"{m}m"
    return f"{m}m{s}s"


# ---------------------------------------------------------------------------
# Stats computation
# ---------------------------------------------------------------------------

def compute_stats(timings: dict, baseline: dict | None = None) -> dict:
    jobs_all = timings.get("jobs", [])
    jobs = [j for j in jobs_all if not is_skipped(j)]
    bl_jobs_all = (baseline or {}).get("jobs", [])
    bl_jobs = [j for j in bl_jobs_all if not is_skipped(j)]
    bl_map = {j["name"]: j for j in bl_jobs}

    # Wall time (skipped jobs have no real timestamps)
    starts = [s for s in (parse_ts(j.get("started_at")) for j in jobs) if s is not None]
    ends = [e for e in (parse_ts(j.get("completed_at")) for j in jobs) if e is not None]
    wall = (max(ends) - min(starts)).total_seconds() if starts and ends else 0
    compute = sum(j.get("duration_s") or 0 for j in jobs)

    # Baseline wall/compute
    bl_wall = None
    bl_compute = None
    if baseline:
        bl_starts = [s for s in (parse_ts(j.get("started_at")) for j in bl_jobs) if s is not None]
        bl_ends = [e for e in (parse_ts(j.get("completed_at")) for j in bl_jobs) if e is not None]
        if bl_starts and bl_ends:
            bl_wall = (max(bl_ends) - min(bl_starts)).total_seconds()
        bl_compute = sum(j.get("duration_s") or 0 for j in bl_jobs)

    # Per-job deltas (skipped excluded)
    faster = 0
    slower = 0
    unchanged = 0
    no_baseline = 0
    for j in jobs:
        bl = bl_map.get(j["name"])
        if not bl:
            no_baseline += 1
            continue
        cur_d = j.get("duration_s") or 0
        bl_d = bl.get("duration_s") or 0
        if abs(cur_d - bl_d) < 1.0:
            unchanged += 1
        elif cur_d > bl_d:
            slower += 1
        else:
            faster += 1

    skipped = sum(1 for j in jobs_all if is_skipped(j))
    bl_skipped = sum(1 for j in bl_jobs_all if is_skipped(j))
    total_wait = sum(j.get("wait_s") or 0 for j in jobs)
    bl_total_wait = sum(j.get("wait_s") or 0 for j in bl_jobs)

    return {
        "wall": wall,
        "compute": compute,
        "bl_wall": bl_wall,
        "bl_compute": bl_compute,
        "faster": faster,
        "slower": slower,
        "unchanged": unchanged,
        "no_baseline": no_baseline,
        "skipped": skipped,
        "bl_skipped": bl_skipped,
        "total_wait": total_wait,
        "bl_total_wait": bl_total_wait,
        "total_jobs": len(jobs_all),
    }


# ---------------------------------------------------------------------------
# HTML generation
# ---------------------------------------------------------------------------

CSS = """
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
  background: #0d1117; color: #e6edf3; line-height: 1.5; padding: 24px;
}
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 8px; }
.meta { color: #8b949e; font-size: 13px; margin-bottom: 24px; }
h2 { font-size: 18px; margin: 32px 0 12px; }

/* Stats cards */
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
.stat-card {
  background: #161b22; border: 1px solid #30363d; border-radius: 8px;
  padding: 14px 18px; min-width: 140px;
}
.stat-label { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
.stat-value { font-size: 22px; font-weight: 600; margin: 4px 0; }
.stat-delta { font-size: 13px; }
.faster { color: #3fb950; }
.slower { color: #f85149; }
.neutral { color: #8b949e; }

/* Gantt */
.gantt-wrap { overflow-x: auto; }
.gantt { min-width: 700px; }
.gantt-row { display: flex; align-items: center; height: 28px; }
.gantt-label {
  width: 220px; padding-right: 12px; text-align: right;
  font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gantt-track { flex: 1; position: relative; height: 100%; border-left: 1px solid #21262d; }
.gantt-bar {
  position: absolute; height: 18px; border-radius: 3px;
  display: flex; align-items: center; justify-content: center;
  font-size: 10px; color: transparent; overflow: hidden;
  transition: color 0.15s;
}
.gantt-bar:hover { color: #fff; z-index: 10; }
.gantt-bar.current { background: #1f6feb; top: 5px; z-index: 2; }
.gantt-bar.wait {
  background: repeating-linear-gradient(
    45deg, #30363d, #30363d 3px, transparent 3px, transparent 6px
  );
  top: 5px; z-index: 1; opacity: 0.6;
}
.gantt-bar.baseline {
  background: transparent; border: 1px dashed #8b949e; top: 2px; height: 24px; z-index: 1;
}
.gantt-axis { display: flex; height: 20px; position: relative; border-top: 1px solid #30363d; margin-top: 4px; }
.gantt-tick { position: absolute; font-size: 10px; color: #8b949e; transform: translateX(-50%); top: 4px; }
.gantt-tick::before { content: ''; position: absolute; top: -4px; left: 50%; width: 1px; height: 4px; background: #30363d; }
.legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: #8b949e; }
.legend-swatch { display: inline-block; width: 16px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }

/* Tables */
table { border-collapse: collapse; width: 100%; font-size: 13px; margin-bottom: 16px; }
th, td { border: 1px solid #30363d; padding: 6px 10px; text-align: left; }
th { background: #161b22; font-weight: 600; position: sticky; top: 0; }
tr:hover td { background: #161b22; }
.num { text-align: right; font-variant-numeric: tabular-nums; }
.job-name { font-weight: 500; }

/* Step details */
details { margin-bottom: 8px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
summary { padding: 8px 12px; cursor: pointer; font-weight: 500; font-size: 14px; user-select: none; }
summary:hover { background: #21262d; }
details[open] summary { border-bottom: 1px solid #30363d; }
details table { border: none; margin: 0; }
details td, details th { font-size: 12px; }

/* Worst regressions */
.regressions { margin-bottom: 24px; }
.regressions table { font-size: 13px; }
.tag {
  display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 500;
}
.tag.slow { background: rgba(248,81,73,0.15); color: #f85149; }
.tag.fast { background: rgba(63,185,80,0.15); color: #3fb950; }
"""


def _gantt_bars(timings: dict, baseline: dict | None) -> str:
    """Render the gantt chart HTML.

    Both current and baseline timelines are normalized to start at t=0
    (relative to each run's earliest job start). The axis scale spans
    0..max_end across both runs so bars are directly comparable.
    """
    jobs = [j for j in timings.get("jobs", [])
            if j.get("started_at") and j.get("completed_at") and not is_skipped(j)]
    bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}

    # Current run: relative offsets from earliest start
    cur_starts = [s for s in (parse_ts(j.get("started_at")) for j in jobs) if s is not None]
    cur_ends = [e for e in (parse_ts(j.get("completed_at")) for j in jobs) if e is not None]
    if not cur_starts or not cur_ends:
        return '<p style="color:#8b949e">No timing data available.</p>'
    cur_t0 = min(cur_starts)
    cur_max = (max(cur_ends) - cur_t0).total_seconds()

    # Baseline run: max duration (for axis scale only — bars are aligned
    # to the current job's start, not to the baseline timeline)
    bl_max_dur = 0.0
    for bl_j in bl_map.values():
        if is_skipped(bl_j):
            continue
        s = parse_ts(bl_j.get("started_at"))
        e = parse_ts(bl_j.get("completed_at"))
        if s is not None and e is not None:
            bl_max_dur = max(bl_max_dur, (e - s).total_seconds())

    total_s = max(cur_max, bl_max_dur)
    if total_s <= 0:
        total_s = 1

    rows = []
    for j in jobs:
        s = parse_ts(j.get("started_at"))
        e = parse_ts(j.get("completed_at"))
        if s is None or e is None:
            continue
        left = (s - cur_t0).total_seconds() / total_s * 100
        width = max((e - s).total_seconds() / total_s * 100, 0.5)  # min 0.5% for visibility
        dur = j.get("duration_s") or 0

        bl = bl_map.get(j["name"])
        bl_bar = ""
        if bl and not is_skipped(bl):
            bl_s = parse_ts(bl.get("started_at"))
            bl_e = parse_ts(bl.get("completed_at"))
            if bl_s is not None and bl_e is not None:
                # Align baseline bar to the current job's start so the
                # two durations are directly comparable — the baseline's
                # own wait/position is irrelevant for a duration diff.
                bl_left = left
                bl_width = max((bl_e - bl_s).total_seconds() / total_s * 100, 0.5)
                bl_dur = bl.get("duration_s") or 0
                bl_bar = (
                    f'<div class="gantt-bar baseline" '
                    f'style="left:{bl_left:.2f}%;width:{bl_width:.2f}%" '
                    f'title="baseline: {fmt_dur(bl_dur)}"></div>'
                )

        name_display = escape(j["name"])
        if j.get("workflow_name"):
            name_display = f'{escape(j["workflow_name"])} / {escape(j["name"])}'

        delta_info = ""
        if bl and not is_skipped(bl) and bl.get("duration_s") is not None:
            d_text, d_cls = fmt_delta(dur, bl.get("duration_s"))
            delta_info = f' — {d_text}'

        # Wait bar: shows idle time before the job started running
        wait_bar = ""
        wait_s = j.get("wait_s")
        if wait_s and wait_s >= 1.0:
            wait_left = (s - cur_t0).total_seconds() - wait_s
            wait_left_pct = max(wait_left / total_s * 100, 0)
            wait_width_pct = max(wait_s / total_s * 100, 0.3)
            wait_bar = (
                f'<div class="gantt-bar wait" '
                f'style="left:{wait_left_pct:.2f}%;width:{wait_width_pct:.2f}%" '
                f'title="{escape(j["name"])} — waited: {fmt_dur(wait_s)}"></div>'
            )

        rows.append(
            f'<div class="gantt-row">'
            f'<div class="gantt-label" title="{escape(j["name"])}">{name_display}</div>'
            f'<div class="gantt-track">'
            f'{bl_bar}'
            f'{wait_bar}'
            f'<div class="gantt-bar current" '
            f'style="left:{left:.2f}%;width:{width:.2f}%" '
            f'title="{escape(j["name"])}: {fmt_dur(dur)}{delta_info}"></div>'
            f'</div></div>'
        )

    # Axis
    ticks = nice_ticks(total_s)
    tick_html = "".join(
        f'<span class="gantt-tick" style="left:{(t / total_s * 100):.1f}%">{fmt_tick(t)}</span>'
        for t in ticks
    )
    axis = f'<div class="gantt-axis"><div class="gantt-track">{tick_html}</div></div>'

    legend = (
        '<div class="legend">'
        '<span><span class="legend-swatch" style="background:#1f6feb"></span>Current</span>'
        '<span><span class="legend-swatch" style="background:repeating-linear-gradient(45deg,#30363d,#30363d 3px,transparent 3px,transparent 6px);opacity:0.6"></span>Wait</span>'
    )
    if baseline:
        legend += '<span><span class="legend-swatch" style="border:1px dashed #8b949e"></span>Baseline (main)</span>'
    legend += '</div>'

    return f'<div class="gantt-wrap"><div class="gantt">{"".join(rows)}{axis}</div></div>{legend}'


def _stats_cards(stats: dict) -> str:
    wall_text = fmt_dur(stats["wall"])
    wall_delta = ""
    if stats["bl_wall"] is not None:
        d, cls = fmt_delta(stats["wall"], stats["bl_wall"])
        wall_delta = f'<span class="stat-delta {cls}">{d}</span>'

    compute_text = fmt_dur(stats["compute"])
    compute_delta = ""
    if stats["bl_compute"] is not None:
        d, cls = fmt_delta(stats["compute"], stats["bl_compute"])
        compute_delta = f'<span class="stat-delta {cls}">{d}</span>'

    cards = [
        f'<div class="stat-card"><span class="stat-label">Wall Time</span>'
        f'<div class="stat-value">{wall_text}</div>{wall_delta}</div>',
        f'<div class="stat-card"><span class="stat-label">Total Compute</span>'
        f'<div class="stat-value">{compute_text}</div>{compute_delta}</div>',
        f'<div class="stat-card"><span class="stat-label">Jobs Faster</span>'
        f'<div class="stat-value faster">{stats["faster"]}</div></div>',
        f'<div class="stat-card"><span class="stat-label">Jobs Slower</span>'
        f'<div class="stat-value slower">{stats["slower"]}</div></div>',
        f'<div class="stat-card"><span class="stat-label">Unchanged</span>'
        f'<div class="stat-value neutral">{stats["unchanged"]}</div></div>',
        f'<div class="stat-card"><span class="stat-label">Skipped</span>'
        f'<div class="stat-value neutral">{stats["skipped"]}</div></div>',
        f'<div class="stat-card"><span class="stat-label">No Baseline</span>'
        f'<div class="stat-value neutral">{stats["no_baseline"]}</div></div>',
    ]
    return f'<div class="stats">{"".join(cards)}</div>'


def _job_table(timings: dict, baseline: dict | None) -> str:
    bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
    rows = []
    for j in timings.get("jobs", []):
        name = escape(j["name"])
        if j.get("workflow_name"):
            name = f'{escape(j["workflow_name"])} / {escape(j["name"])}'

        concl = j.get("conclusion", "")
        concl_icon = {"success": "✓", "failure": "✗", "skipped": "⊘"}.get(concl, "?")
        concl_cls = {"success": "faster", "failure": "slower", "skipped": "neutral"}.get(concl, "neutral")

        if is_skipped(j):
            rows.append(
                f'<tr>'
                f'<td class="job-name">{name}</td>'
                f'<td class="num neutral">skipped</td>'
                f'<td class="num neutral">—</td>'
                f'<td class="num neutral">—</td>'
                f'<td class="num neutral">—</td>'
                f'<td class="num neutral">—</td>'
                f'<td class="{concl_cls}" style="text-align:center">{concl_icon}</td>'
                f'</tr>'
            )
            continue

        dur = j.get("duration_s")
        wait = j.get("wait_s")
        bl = bl_map.get(j["name"])
        bl_dur = bl.get("duration_s") if bl and not is_skipped(bl) else None
        bl_wait = bl.get("wait_s") if bl and not is_skipped(bl) else None
        delta_text, delta_cls = fmt_delta(dur, bl_dur)
        wait_delta_text, wait_delta_cls = fmt_delta(wait, bl_wait)

        rows.append(
            f'<tr>'
            f'<td class="job-name">{name}</td>'
            f'<td class="num">{fmt_dur(dur)}</td>'
            f'<td class="num">{fmt_dur(wait)}</td>'
            f'<td class="num">{fmt_dur(bl_dur)}</td>'
            f'<td class="num {delta_cls}">{delta_text}</td>'
            f'<td class="num {wait_delta_cls}">{wait_delta_text}</td>'
            f'<td class="{concl_cls}" style="text-align:center">{concl_icon}</td>'
            f'</tr>'
        )

    return (
        '<table><thead><tr>'
        '<th>Job</th><th class="num">Run</th><th class="num">Wait</th>'
        '<th class="num">Baseline</th><th class="num">Δ Run</th>'
        '<th class="num">Δ Wait</th><th>Status</th>'
        '</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
    )


def _step_details(timings: dict, baseline: dict | None) -> str:
    bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
    blocks = []
    for j in timings.get("jobs", []):
        if not j.get("steps"):
            continue
        if is_skipped(j):
            continue
        bl = bl_map.get(j["name"], {})
        bl_steps = {s["name"]: s for s in bl.get("steps", [])}

        dur = j.get("duration_s") or 0
        wait = j.get("wait_s")
        bl_dur = bl.get("duration_s") if bl and not is_skipped(bl) else None
        delta_text, delta_cls = fmt_delta(dur, bl_dur)

        summary_text = f'{escape(j["name"])} — {fmt_dur(dur)}'
        if wait is not None and wait >= 1.0:
            summary_text += f' <span class="neutral">(wait {fmt_dur(wait)})</span>'
        if bl_dur is not None:
            summary_text += f' <span class="{delta_cls}">({delta_text})</span>'

        step_rows = []
        for s in j["steps"]:
            s_dur = s.get("duration_s")
            bl_s = bl_steps.get(s["name"])
            bl_s_dur = bl_s.get("duration_s") if bl_s else None
            s_delta, s_cls = fmt_delta(s_dur, bl_s_dur)

            step_rows.append(
                f'<tr>'
                f'<td>{escape(s["name"])}</td>'
                f'<td class="num">{fmt_dur(s_dur)}</td>'
                f'<td class="num">{fmt_dur(bl_s_dur)}</td>'
                f'<td class="num {s_cls}">{s_delta}</td>'
                f'</tr>'
            )

        blocks.append(
            f'<details><summary>{summary_text}</summary>'
            f'<table><thead><tr>'
            '<th>Step</th><th class="num">Current</th><th class="num">Baseline</th>'
            '<th class="num">Delta</th>'
            f'</tr></thead><tbody>{"".join(step_rows)}</tbody></table>'
            f'</details>'
        )

    return "".join(blocks) if blocks else '<p style="color:#8b949e">No step data available.</p>'


def _regressions(timings: dict, baseline: dict | None) -> str:
    """Show top 10 biggest absolute regressions/improvements across all steps."""
    if not baseline:
        return ""
    bl_map = {j["name"]: j for j in baseline.get("jobs", [])}

    deltas = []  # (abs_delta, job_name, step_name, current, baseline, is_slower)
    for j in timings.get("jobs", []):
        if is_skipped(j):
            continue
        bl = bl_map.get(j["name"])
        if not bl or is_skipped(bl):
            continue
        bl_steps = {s["name"]: s for s in bl.get("steps", [])}
        for s in j.get("steps", []):
            bl_s = bl_steps.get(s["name"])
            if not bl_s:
                continue
            cur = s.get("duration_s") or 0
            bl_d = bl_s.get("duration_s") or 0
            diff = cur - bl_d
            if abs(diff) < 1.0:
                continue
            deltas.append((abs(diff), diff, j["name"], s["name"], cur, bl_d))

    deltas.sort(key=lambda x: x[0], reverse=True)
    top = deltas[:10]
    if not top:
        return ""

    rows = []
    for _, diff, job, step, cur, bl_d in top:
        cls = "slower" if diff > 0 else "faster"
        tag = f'<span class="tag {"slow" if diff > 0 else "fast"}">{"+" if diff > 0 else ""}{diff:.1f}s</span>'
        rows.append(
            f'<tr>'
            f'<td class="job-name">{escape(job)}</td>'
            f'<td>{escape(step)}</td>'
            f'<td class="num">{fmt_dur(cur)}</td>'
            f'<td class="num">{fmt_dur(bl_d)}</td>'
            f'<td>{tag}</td>'
            f'</tr>'
        )

    return (
        '<div class="regressions">'
        '<table><thead><tr>'
        '<th>Job</th><th>Step</th><th class="num">Current</th><th class="num">Baseline</th>'
        '<th>Delta</th>'
        '</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
        '</div>'
    )


def generate_html(timings: dict, baseline: dict | None = None) -> str:
    stats = compute_stats(timings, baseline)

    sha_short = (timings.get("head_sha") or "")[:7]
    run_id = timings.get("run_id", "?")
    created = timings.get("created_at", "")

    bl_info = ""
    if baseline:
        bl_sha = (baseline.get("head_sha") or "")[:7]
        bl_info = f' | Baseline: <code>{bl_sha}</code> (main)'

    html = (
        f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
        f'<meta charset="utf-8">\n'
        f'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
        f'<title>CI Timing Report — {sha_short}</title>\n'
        f'<style>{CSS}</style>\n'
        f'</head>\n<body>\n'
        f'<h1>CI Timing Report</h1>\n'
        f'<div class="meta">Run <code>{escape(run_id)}</code> | SHA <code>{sha_short}</code>'
        f' | Generated {escape(created)}{bl_info}</div>\n'
    )

    html += '<h2>Global Stats</h2>\n'
    html += _stats_cards(stats)

    if baseline:
        html += '<h2>Top Regressions & Improvements</h2>\n'
        html += _regressions(timings, baseline)

    html += '<h2>Gantt Chart</h2>\n'
    html += _gantt_bars(timings, baseline)

    html += '<h2>Per-Job Comparison</h2>\n'
    html += _job_table(timings, baseline)

    html += '<h2>Step Details</h2>\n'
    html += _step_details(timings, baseline)

    html += '</body>\n</html>\n'
    return html


# ---------------------------------------------------------------------------
# Markdown summary for $GITHUB_STEP_SUMMARY
# ---------------------------------------------------------------------------

def generate_summary(timings: dict, baseline: dict | None = None) -> str:
    stats = compute_stats(timings, baseline)
    bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}

    lines = ["## CI Timing Summary\n"]

    # Global stats table
    lines.append("| Metric | Current | Baseline | Delta |")
    lines.append("|--------|---------|----------|-------|")

    wall_d = ""
    if stats["bl_wall"] is not None:
        d, _ = fmt_delta(stats["wall"], stats["bl_wall"])
        wall_d = d
    lines.append(f"| Wall time | {fmt_dur(stats['wall'])} | {fmt_dur(stats['bl_wall'])} | {wall_d} |")

    compute_d = ""
    if stats["bl_compute"] is not None:
        d, _ = fmt_delta(stats["compute"], stats["bl_compute"])
        compute_d = d
    lines.append(f"| Total compute | {fmt_dur(stats['compute'])} | {fmt_dur(stats['bl_compute'])} | {compute_d} |")

    wait_d = ""
    if stats["bl_total_wait"] is not None:
        d, _ = fmt_delta(stats["total_wait"], stats["bl_total_wait"])
        wait_d = d
    lines.append(f"| Total wait | {fmt_dur(stats['total_wait'])} | {fmt_dur(stats['bl_total_wait'])} | {wait_d} |")

    lines.append(f"| Jobs faster | {stats['faster']} | — | — |")
    lines.append(f"| Jobs slower | {stats['slower']} | — | — |")
    lines.append(f"| Jobs unchanged | {stats['unchanged']} | — | — |")
    lines.append(f"| Jobs skipped | {stats['skipped']} | {stats['bl_skipped']} | — |")
    lines.append(f"| Jobs without baseline | {stats['no_baseline']} | — | — |")
    lines.append("")

    return "\n".join(lines)


# ---------------------------------------------------------------------------
# Review status JSON for the unified PR comment
# ---------------------------------------------------------------------------

# Wall-time regressions above this fraction of baseline are "warning" severity.
_TIMINGS_WARN_PCT = 0.25


def generate_review_status(
    timings: dict, baseline: dict | None, report_url: str | None = None
) -> list[dict]:
    """Produce a review_status JSON array for the CI timings review section.

    Returns a list with one ``{source, results: [...]}`` entry. The
    result kind is ``"debug"`` or ``"warning"`` (timings is never error —
    it's an observability job). *summary* is a single short line suitable
    for the PR comment. *detail* has the per-job deltas as a markdown
    fragment.
    """
    stats = compute_stats(timings, baseline)

    if baseline is None:
        severity = "debug"
        summary = f"Wall time {fmt_dur(stats['wall'])} (no baseline yet)."
    else:
        wall = stats["wall"]
        bl_wall = stats["bl_wall"] or 0
        if bl_wall > 0:
            pct = (wall - bl_wall) / bl_wall * 100
            wall_str = f"Wall time {fmt_dur(wall)} vs {fmt_dur(bl_wall)} ({pct:+.1f}%)."
            if pct > _TIMINGS_WARN_PCT * 100:
                severity = "warning"
            else:
                severity = "debug"
        else:
            wall_str = f"Wall time {fmt_dur(wall)}."
            severity = "debug"

        if stats["slower"]:
            wall_str += f" {stats['slower']} job(s) slower,"
        if stats["faster"]:
            wall_str += f" {stats['faster']} faster,"
        if stats["unchanged"]:
            wall_str += f" {stats['unchanged']} unchanged."
        summary = wall_str

    # Per-job delta detail (top 5 by absolute change)
    detail_lines: list[str] = []
    if baseline:
        bl_map = {j["name"]: j for j in baseline.get("jobs", [])}
        deltas: list[tuple[float, str, str]] = []
        for j in timings.get("jobs", []):
            if is_skipped(j):
                continue
            bl = bl_map.get(j["name"])
            if not bl or is_skipped(bl):
                continue
            cur = j.get("duration_s") or 0
            bl_d = bl.get("duration_s") or 0
            diff = cur - bl_d
            if abs(diff) < 1.0:
                continue
            deltas.append((abs(diff), j["name"], f"{diff:+.1f}s"))
        deltas.sort(reverse=True)
        for _, name, delta_str in deltas[:5]:
            detail_lines.append(f"- {name}: {delta_str}")

    result: dict = {
        "kind": severity,
        "title": "CI timings",
        "summary": summary,
        "detail": "\n".join(detail_lines),
    }
    if report_url:
        result["link"] = report_url
        result["link_label"] = "View report"

    return [{"source": "ci timing", "results": [result]}]


# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------

def expect_env(var: str) -> str:
    val = os.environ.get(var)
    if not val:
        raise ValueError(f"missing environment variable {var}")
    return val

def main():
    parser = argparse.ArgumentParser(description="Collect CI timings and generate HTML report")
    parser.add_argument("--from-json", help="Read timings from JSON instead of API")
    parser.add_argument("--baseline", default="ci-timings-baseline.json",
                        help="Baseline JSON path (default: ci-timings-baseline.json)")
    parser.add_argument("--output", default="ci-timings-report.html",
                        help="HTML output path (default: ci-timings-report.html)")
    parser.add_argument("--json-out", default="ci-timings.json",
                        help="JSON output path (default: ci-timings.json)")
    parser.add_argument("--summary-out", default="ci-timings-summary.md",
                        help="Markdown summary output path (default: ci-timings-summary.md)")
    parser.add_argument("--review-status-out", default="",
                        help="If set, write a review-status JSON for the unified PR comment.")
    parser.add_argument("--review-status-only", action="store_true",
                        help="Write review status from existing timings without regenerating the report.")
    args = parser.parse_args()

    # Collect or load timings
    if args.from_json:
        with open(args.from_json, encoding="utf-8") as f:
            timings = json.load(f)
    else:
        repo = expect_env("GITHUB_REPOSITORY")
        run_id = expect_env("GITHUB_RUN_ID")
        head_sha = expect_env("GITHUB_SHA")
        try:
            # A missing token (e.g. an empty PAT on a fork PR, where repo
            # secrets are unavailable) is a degraded run, not a hard error:
            # route it through the same soft-fail path so this advisory job
            # never reddens the PR.
            token = os.environ.get("GITHUB_TOKEN")
            if not token:
                raise TimingsUnavailable("GITHUB_TOKEN is empty")
            timings = collect_timings(token, repo, run_id, head_sha)
        except TimingsUnavailable as e:
            # Observability job: a missing report must never redden the PR.
            # Emit a degraded summary + placeholder artifact and exit 0.
            msg = f"CI timing data unavailable this run: {e}"
            print(msg, file=sys.stderr)
            with open(args.summary_out, "a", encoding="utf-8") as f:
                f.write(f"\n> ⚠️ {msg}\n")
            with open(args.output, "w", encoding="utf-8") as f:
                f.write(f"<html><body><p>{escape(msg)}</p></body></html>\n")
            # No JSON on purpose: an empty timings file must never be cached
            # as the main baseline.
            sys.exit(0)

    # Save JSON
    with open(args.json_out, "w", encoding="utf-8") as f:
        json.dump(timings, f, indent=2)
    print(f"Saved timings to {args.json_out} ({len(timings.get('jobs', []))} jobs)")

    # Load baseline
    baseline = None
    if os.path.exists(args.baseline):
        with open(args.baseline, encoding="utf-8") as f:
            baseline = json.load(f)
        print(f"Loaded baseline from {args.baseline}")
    else:
        print(f"No baseline file at {args.baseline} — generating current-only report")

    if args.review_status_only:
        if not args.review_status_out:
            parser.error("--review-status-only requires --review-status-out")
        report_url = os.environ.get("CI_TIMINGS_REPORT_URL", "")
        statuses = generate_review_status(timings, baseline, report_url)
        with open(args.review_status_out, "w", encoding="utf-8") as f:
            f.write(f"review_status={json.dumps(statuses)}\n")
        print(f"Wrote review status to {args.review_status_out}")
        return

    # Generate HTML
    html = generate_html(timings, baseline)
    with open(args.output, "w", encoding="utf-8") as f:
        f.write(html)
    print(f"Generated HTML report: {args.output}")

    # Write summary
    summary = generate_summary(timings, baseline)
    with open(args.summary_out, "a", encoding="utf-8") as f:
        f.write(summary)
        print(f"Wrote summary to {args.summary_out}")

    # Write review status for the unified PR comment.
    # The output goes to GITHUB_OUTPUT (or a file with the same key=value
    # format) so the ci-timings job can expose it as a workflow_call output.
    if args.review_status_out:
        report_url = os.environ.get("CI_TIMINGS_REPORT_URL", "")
        statuses = generate_review_status(timings, baseline, report_url)
        json_str = json.dumps(statuses)
        with open(args.review_status_out, "a", encoding="utf-8") as f:
            f.write(f"review_status={json_str}\n")
        print(f"Wrote review status to {args.review_status_out}")


if __name__ == "__main__":
    main()
