#!/usr/bin/env python3
"""
make_report_html.py - turn run_benchmarks.sh output into a self-contained HTML report.

Reads the artifacts produced by the benchmark harness (all under bench_logs/<power_W>/):
  - <alias>.table.md          (llama-bench markdown results table)
  - <alias>.nvidia-smi.csv    (thermal/power/clock time series)
  - manifest.tsv              (optional; alias -> hf spec / placement / note / devices)
  - benchmark_results.md      (optional; for the System / config header)

Emits one HTML file with inline SVG charts (no external JS/CSS, works offline):
  - Prefill throughput (tokens/sec) by model
  - Decode throughput (tokens/sec) by model, including the at-depth measurements
  - Max core temperature, max memory temperature, max and average power by model/GPU
  - Per-model temperature, power, and SM-clock time-series, automatically trimmed
    to the active benchmark window (model load and idle tails are cut so the
    interesting part fills the chart)
  - A compact summary table with colour-coding, GPUs-used, average power, peak
    VRAM, and a tokens/sec per average-watt efficiency column

Usage:
  # logdir defaults to bench_logs; results to <logdir>/benchmark_results.md
  python3 make_report_html.py --logdir bench_logs/400
  python3 make_report_html.py --logdir bench_logs/400 --out report_400w.html
  python3 make_report_html.py --logdir bench_logs/400 --no-trim   # full recording

Standard library only. Requires Python 3.8+.
"""

import argparse
import glob
import html
import math
import os
import re
import sys
from datetime import datetime

# ----------------------------- palette ----------------------------------------
GPU_COLORS  = ["#2563eb", "#db2777", "#16a34a", "#d97706", "#7c3aed", "#0891b2"]
PP_COLORS   = ["#2563eb", "#14b8a6", "#6366f1", "#0ea5e9"]
TG_COLORS   = ["#db2777", "#f59e0b", "#16a34a", "#7c3aed"]
MEMTEMP_COLOR = "#ea580c"

# ----------------------------- activity detection -----------------------------
# A sample counts as "active" (benchmark actually running, not model loading or
# idle) when GPU utilisation or power crosses these thresholds.  Loading a GGUF
# from disk shows low GPU utilisation and near-idle power, so this reliably
# separates the load phase from the measured runs.
ACTIVE_UTIL_PCT = 25.0    # % utilization.gpu
ACTIVE_POWER_W  = 150.0   # watts
ACTIVE_PAD_S    = 4.0     # seconds of margin kept on each side of the window
# A GPU counts as "used" by a run when, inside the active window, it crosses:
USED_UTIL_PCT   = 50.0
USED_POWER_W    = 150.0

# ----------------------------- throttle reason table --------------------------
# bit, human name, category (benign / power / thermal)
THROTTLE_REASONS = [
    (0x001, "GPU idle",                   "benign"),
    (0x002, "Applications clocks setting","benign"),
    (0x004, "SW power cap",               "power"),
    (0x008, "HW slowdown",                "thermal"),
    (0x010, "Sync boost",                 "benign"),
    (0x020, "SW thermal slowdown",        "thermal"),
    (0x040, "HW thermal slowdown",        "thermal"),
    (0x080, "HW power brake slowdown",    "power"),
    (0x100, "Display clock setting",      "benign"),
]


def decode_throttle(bits):
    """bits(int) -> (concern, notable_reason_names).
    concern is 'thermal' > 'power' > 'none'.  Benign-only reasons are suppressed."""
    cats = set()
    for bit, _, cat in THROTTLE_REASONS:
        if bits & bit:
            cats.add(cat)
    if "thermal" in cats:
        concern = "thermal"
    elif "power" in cats:
        concern = "power"
    else:
        concern = "none"
    notable = [name for bit, name, cat in THROTTLE_REASONS
               if (bits & bit) and cat in ("power", "thermal")]
    return concern, notable


# ----------------------------- utilities --------------------------------------
def esc(s):
    return html.escape(str(s), quote=True)


def anchor_id(s):
    """Turn an arbitrary string into a safe HTML id attribute value."""
    return re.sub(r"[^a-zA-Z0-9_-]", "-", str(s))


# ----------------------------- test-label parsing -----------------------------
def parse_test_label(label):
    """'pp512' -> ('pp',512,0);  'tg128 @ d16384' -> ('tg',128,16384)."""
    m = re.match(r"^(pp|tg)(\d+)(?:\s*@\s*d(\d+))?$", label.strip())
    if not m:
        return None
    return m.group(1), int(m.group(2)), int(m.group(3)) if m.group(3) else 0


def norm_test_key(kind, n, depth):
    return f"{kind}{n}@d{depth}"


def pretty_test(kind, n, depth):
    word = "Prefill" if kind == "pp" else "Decode"
    return f"{word} {n} @ d{depth}"


# Module-level sort helpers (used in sorted() calls).
def _key_depth(norm_key):
    p = parse_test_label(norm_key.replace("@d", " @ d") if "@d" in norm_key else norm_key)
    return p[2] if p else 0

def _key_n(norm_key):
    p = parse_test_label(norm_key.replace("@d", " @ d") if "@d" in norm_key else norm_key)
    return p[1] if p else 0


# ----------------------------- log parsing ------------------------------------
def parse_table_md(path):
    """Return {norm_key: (kind,n,depth,value,stddev)} from a llama-bench -o md table."""
    out = {}
    with open(path, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            if "|" not in line:
                continue
            cells = [c.strip() for c in line.split("|")]
            test = ts = lastnum = None
            for c in cells:
                if re.match(r"^(pp|tg)\d", c):
                    test = c
                if "\u00b1" in c:
                    ts = c
                elif re.match(r"^\d+\.\d+$", c):
                    lastnum = c
            if ts is None:
                ts = lastnum
            if not test or not ts:
                continue
            parsed = parse_test_label(test)
            if not parsed:
                continue
            m = re.match(r"^([\d.]+)\s*(?:\u00b1\s*([\d.]+))?$", ts)
            if not m:
                continue
            kind, n, depth = parsed
            val = float(m.group(1))
            sd  = float(m.group(2)) if m.group(2) else 0.0
            out[norm_test_key(kind, n, depth)] = (kind, n, depth, val, sd)
    return out


def _num(x):
    try:
        return float(x)
    except (TypeError, ValueError):
        return None


def parse_smi_csv(path):
    """Parse the nvidia-smi CSV produced by the harness.
    Returns {gpu_index(str): [records]} with relative time in seconds.

    Column layout (order of --query-gpu in the harness):
      0 timestamp, 1 index, 2 temperature.gpu, 3 temperature.memory,
      4 power.draw, 5 clocks.sm, 6 clocks.mem, 7 utilization.gpu,
      8 utilization.memory, 9 clocks_throttle_reasons.active,
      10 memory.used (MiB; only present in CSVs from the updated harness)
    Older 10-column CSVs are handled transparently."""
    data = {}
    t0 = None
    with open(path, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            parts = [p.strip() for p in line.split(",")]
            if len(parts) < 5:
                continue
            try:
                tstamp = datetime.strptime(parts[0], "%Y/%m/%d %H:%M:%S.%f")
            except ValueError:
                continue
            if t0 is None:
                t0 = tstamp
            thr_raw = parts[9].strip() if len(parts) > 9 else ""
            try:
                thr_bits = int(thr_raw, 16)
            except ValueError:
                thr_bits = 0
            rec = {
                "t":      (tstamp - t0).total_seconds(),
                "temp":   _num(parts[2]),
                "memtemp":_num(parts[3]),
                "power":  _num(parts[4]),
                "smclk":  _num(parts[5]) if len(parts) > 5 else None,
                "util":   _num(parts[7]) if len(parts) > 7 else None,
                "vram":   _num(parts[10]) if len(parts) > 10 else None,  # MiB
                "throttle_bits": thr_bits,
            }
            data.setdefault(parts[1], []).append(rec)
    return data


def _is_active(rec):
    u = rec.get("util")
    p = rec.get("power")
    return ((u is not None and u >= ACTIVE_UTIL_PCT)
            or (p is not None and p >= ACTIVE_POWER_W))


def active_window(smi):
    """Find the [t_start, t_end] window during which the benchmark was actually
    running (any GPU active), padded by ACTIVE_PAD_S.  Returns (t0, t1, total)
    where total is the full recording length; (None, None, total) if no
    activity was detected (charts then fall back to the full range)."""
    total = max((r["t"] for recs in smi.values() for r in recs), default=0.0)
    actives = [r["t"] for recs in smi.values() for r in recs if _is_active(r)]
    if not actives:
        return None, None, total
    t0 = max(0.0, min(actives) - ACTIVE_PAD_S)
    t1 = min(total, max(actives) + ACTIVE_PAD_S)
    return t0, t1, total


def trim_smi(smi, t0, t1):
    """Return a copy of smi restricted to [t0, t1] with time re-zeroed."""
    if t0 is None:
        return smi
    out = {}
    for idx, recs in smi.items():
        sel = [dict(r, t=r["t"] - t0) for r in recs if t0 <= r["t"] <= t1]
        if sel:
            out[idx] = sel
    return out


def smi_summary(smi):
    """Per-GPU maxima, averages, activity flag, and decoded throttle, computed
    over the (already trimmed) active window.  Returns {idx: {...}}."""
    out = {}
    for idx, recs in smi.items():
        temps    = [r["temp"]    for r in recs if r["temp"]    is not None]
        memtemps = [r["memtemp"] for r in recs if r["memtemp"] is not None]
        powers   = [r["power"]   for r in recs if r["power"]   is not None]
        utils    = [r["util"]    for r in recs if r["util"]    is not None]
        vrams    = [r["vram"]    for r in recs if r["vram"]    is not None]
        agg = 0
        for r in recs:
            agg |= r.get("throttle_bits", 0)
        concern, reasons = decode_throttle(agg)
        used = ((max(utils) >= USED_UTIL_PCT) if utils else False) or \
               ((max(powers) >= USED_POWER_W) if powers else False)
        out[idx] = {
            "max_temp":    max(temps)    if temps    else None,
            "max_memtemp": max(memtemps) if memtemps else None,
            "max_power":   max(powers)   if powers   else None,
            "avg_power":   (sum(powers) / len(powers)) if powers else None,
            "max_vram":    max(vrams)    if vrams    else None,   # MiB
            "used":        used,
            "throttle_concern": concern,
            "throttle_reasons": reasons,
        }
    return out


def model_throttle(summ, used_only=True):
    """Combine per-GPU throttle info -> (worst_concern, sorted_reasons).
    By default only GPUs that actually did work count: an idle card parked at
    its idle clocks must not flag the run."""
    order = {"none": 0, "power": 1, "thermal": 2}
    concern = "none"
    reasons = set()
    for s in summ.values():
        if used_only and not s["used"]:
            continue
        if order[s["throttle_concern"]] > order[concern]:
            concern = s["throttle_concern"]
        reasons.update(s["throttle_reasons"])
    return concern, sorted(reasons)


def parse_manifest(path):
    """manifest.tsv lines: alias \\t hf_spec \\t placement \\t note \\t devices
    Returns {alias: {hf, placement, note, devices}}."""
    out = {}
    if not path or not os.path.isfile(path):
        return out
    with open(path, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            parts = line.rstrip("\n").split("\t")
            if len(parts) < 2 or parts[0].startswith("#"):
                continue
            out[parts[0]] = {
                "hf":        parts[1] if len(parts) > 1 else "",
                "placement": parts[2] if len(parts) > 2 else "",
                "note":      parts[3] if len(parts) > 3 else "",
                "devices":   parts[4] if len(parts) > 4 else "",
            }
    return out


def parse_results_md_header(path):
    """Best-effort extraction of the System and Global config tables."""
    if not path or not os.path.isfile(path):
        return [], []
    section = None
    sys_rows, cfg_rows = [], []
    with open(path, encoding="utf-8", errors="replace") as fh:
        for line in fh:
            s = line.strip()
            if s.startswith("## "):
                title = s[3:].lower()
                if title.startswith("system"):
                    section = "sys"
                elif title.startswith("global benchmark"):
                    section = "cfg"
                else:
                    section = None
                continue
            if section and s.startswith("|"):
                cells = [c.strip() for c in s.strip("|").split("|")]
                if len(cells) < 2:
                    continue
                joined = "".join(cells)
                if joined and set(joined) <= set("-: "):
                    continue
                if cells[0].lower() in ("component", "parameter"):
                    continue
                (sys_rows if section == "sys" else cfg_rows).append((cells[0], cells[1]))
    return sys_rows, cfg_rows


def find_power_limit(sys_rows, cfg_rows, logdir):
    """Extract the configured power limit in watts, if discoverable."""
    for rows in (cfg_rows, sys_rows):
        for k, v in rows:
            if "power limit" in k.lower():
                m = re.search(r"(\d+)\s*W", v)
                if m:
                    return float(m.group(1))
    # fall back to the directory-name convention bench_logs/<watts>/
    base = os.path.basename(os.path.normpath(logdir))
    if base.isdigit():
        return float(base)
    return None


# ----------------------------- SVG helpers ------------------------------------
def nice_ticks(maxv, n=5):
    if maxv <= 0:
        return [0]
    raw = maxv / n
    mag = 10 ** math.floor(math.log10(raw))
    norm = raw / mag
    if norm < 1.5:
        step = 1 * mag
    elif norm < 3:
        step = 2 * mag
    elif norm < 7:
        step = 5 * mag
    else:
        step = 10 * mag
    ticks = []
    v = 0.0
    while v < maxv - step * 1e-9:
        ticks.append(v)
        v += step
    ticks.append(v)   # always include one tick >= maxv so bars don't overflow
    return ticks


def fmt_num(v):
    if v is None:
        return "n/a"
    if v >= 100:
        return f"{v:.0f}"
    if v >= 10:
        return f"{v:.1f}"
    return f"{v:.2f}"


def svg_grouped_hbars(items, series, colors, unit, width=860):
    """Horizontal grouped bar chart.
    items:  [(group_label, {series_key: value_or_None}), ...]
    series: [(series_key, display_label), ...]
    Each bar includes an SVG <title> tooltip for hover text.
    """
    if not items:
        return '<p class="empty">No data available for this chart.</p>'
    n_series  = len(series)
    row_h     = 18
    grp_gap   = 14
    grp_h     = n_series * row_h + grp_gap
    label_w   = 200
    right_pad = 84
    top       = 54
    plot_w    = width - label_w - right_pad
    height    = top + len(items) * grp_h + 18

    allvals = [v for _, d in items for v in d.values() if v is not None]
    maxv    = max(allvals) if allvals else 1.0
    ticks   = nice_ticks(maxv)
    maxtick = ticks[-1] if ticks else maxv
    maxtick = maxtick or 1.0

    def x(v):
        return label_w + (v / maxtick) * plot_w

    p = [f'<svg viewBox="0 0 {width} {height}" class="chart" '
         f'xmlns="http://www.w3.org/2000/svg" role="img">']

    # legend
    lx = label_w
    for i, (_, disp) in enumerate(series):
        c = colors[i % len(colors)]
        p.append(f'<rect x="{lx}" y="20" width="12" height="12" rx="2" fill="{c}"/>')
        p.append(f'<text x="{lx + 17}" y="30" class="leg">{esc(disp)}</text>')
        lx += 24 + 8 * len(disp)

    # vertical grid-lines and tick labels
    for t in ticks:
        gx = x(t)
        p.append(f'<line x1="{gx:.1f}" y1="{top - 6}" x2="{gx:.1f}" y2="{height - 14}" '
                 f'class="grid"/>')
        p.append(f'<text x="{gx:.1f}" y="{height - 2}" class="tick" '
                 f'text-anchor="middle">{fmt_num(t)}</text>')
    p.append(f'<text x="{label_w + plot_w / 2:.0f}" y="48" class="axislabel" '
             f'text-anchor="middle">{esc(unit)}</text>')

    y = top
    for glabel, d in items:
        gy_center = y + (grp_h - grp_gap) / 2
        p.append(f'<text x="{label_w - 10}" y="{gy_center + 4:.1f}" class="grouplabel" '
                 f'text-anchor="end">{esc(glabel)}</text>')
        ry = y
        for i, (skey, disp) in enumerate(series):
            v = d.get(skey)
            c = colors[i % len(colors)]
            if v is not None:
                bw      = max(1.0, x(v) - label_w)
                tooltip = esc(f"{glabel} \u2014 {disp}: {fmt_num(v)}")
                p.append(f'<rect x="{label_w}" y="{ry}" width="{bw:.1f}" '
                         f'height="{row_h - 4}" rx="2" fill="{c}">'
                         f'<title>{tooltip}</title></rect>')
                p.append(f'<text x="{label_w + bw + 5:.1f}" y="{ry + row_h - 8:.1f}" '
                         f'class="barval">{fmt_num(v)}</text>')
            ry += row_h
        y += grp_h
    p.append("</svg>")
    return "".join(p)


def _segments(points):
    """Split [(x, y_or_None)] into continuous segments [(x,y)]."""
    segs, cur = [], []
    for xx, yy in points:
        if yy is None:
            if cur:
                segs.append(cur)
            cur = []
        else:
            cur.append((xx, yy))
    if cur:
        segs.append(cur)
    return segs


def svg_line_chart(title, lines, x_max, y_unit, width=440, height=250, y_min=0,
                   hline=None):
    """lines: [(name, color, [(x, y_or_None), ...]), ...]
    hline: optional (value, label) horizontal reference, e.g. the power limit."""
    has = any(any(p[1] is not None for p in pts) for _, _, pts in lines)
    if not has or x_max <= 0:
        return (f'<div class="linewrap"><div class="linetitle">{esc(title)}</div>'
                f'<p class="empty">No data.</p></div>')
    left, right, top, bot = 52, 12, 30, 34
    pw = width  - left - right
    ph = height - top  - bot
    ally  = [p[1] for _, _, pts in lines for p in pts if p[1] is not None]
    if hline:
        ally.append(hline[0])
    y_max = max(ally) if ally else 1.0
    y_max = y_max * 1.08 + 1e-9
    yticks = nice_ticks(y_max)
    y_max  = yticks[-1] if yticks else y_max

    def X(v):
        return left + (v / x_max) * pw

    def Y(v):
        return top + ph - ((v - y_min) / (y_max - y_min)) * ph

    p = [f'<div class="linewrap"><div class="linetitle">{esc(title)}</div>',
         f'<svg viewBox="0 0 {width} {height}" class="chart" '
         f'xmlns="http://www.w3.org/2000/svg" role="img">']

    # y grid + labels
    for t in yticks:
        gy = Y(t)
        p.append(f'<line x1="{left}" y1="{gy:.1f}" x2="{width - right}" y2="{gy:.1f}" '
                 f'class="grid"/>')
        p.append(f'<text x="{left - 6}" y="{gy + 3:.1f}" class="tick" '
                 f'text-anchor="end">{fmt_num(t)}</text>')
    # x ticks
    for t in nice_ticks(x_max, 5):
        gx = X(t)
        p.append(f'<line x1="{gx:.1f}" y1="{top}" x2="{gx:.1f}" y2="{top + ph}" '
                 f'class="grid"/>')
        p.append(f'<text x="{gx:.1f}" y="{top + ph + 14:.1f}" class="tick" '
                 f'text-anchor="middle">{fmt_num(t)}</text>')
    p.append(f'<text x="{left - 40}" y="{top - 12}" class="axislabel">{esc(y_unit)}</text>')
    p.append(f'<text x="{left + pw / 2:.0f}" y="{height - 4}" class="axislabel" '
             f'text-anchor="middle">seconds (active window)</text>')

    # reference line (e.g. configured power limit)
    if hline:
        hy = Y(hline[0])
        p.append(f'<line x1="{left}" y1="{hy:.1f}" x2="{width - right}" y2="{hy:.1f}" '
                 f'stroke="#9ca3af" stroke-width="1" stroke-dasharray="5,4"/>')
        p.append(f'<text x="{width - right - 4}" y="{hy - 4:.1f}" class="tick" '
                 f'text-anchor="end">{esc(hline[1])}</text>')

    # polylines
    for _, color, pts in lines:
        for seg in _segments(pts):
            d = " ".join(f"{X(xx):.1f},{Y(yy):.1f}" for xx, yy in seg)
            p.append(f'<polyline points="{d}" fill="none" stroke="{color}" '
                     f'stroke-width="1.6"/>')

    # legend
    lx = left + 4
    for name, color, pts in lines:
        if not any(pp[1] is not None for pp in pts):
            continue
        p.append(f'<rect x="{lx}" y="{top - 22}" width="10" height="10" rx="2" '
                 f'fill="{color}"/>')
        p.append(f'<text x="{lx + 14}" y="{top - 13}" class="leg">{esc(name)}</text>')
        lx += 22 + 7 * len(name)
    p.append("</svg></div>")
    return "".join(p)


def downsample(recs, target=600):
    if len(recs) <= target:
        return recs
    k = math.ceil(len(recs) / target)
    return recs[::k]


# ----------------------------- CSS -------------------------------------------
CSS = """
:root {
  --fg:   #1f2937;
  --muted:#6b7280;
  --line: #e5e7eb;
  --bg:   #ffffff;
  --card: #f9fafb;
}
@media (prefers-color-scheme: dark) {
  :root {
    --fg:   #f1f5f9;
    --muted:#94a3b8;
    --line: #2d3748;
    --bg:   #0f172a;
    --card: #1e293b;
  }
  tbody tr:hover { background: #263044; }
}
* { box-sizing: border-box; }
body { margin:0; padding:32px; background:var(--bg); color:var(--fg);
  font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto,
               Helvetica, Arial, sans-serif;
  line-height:1.45; }
h1 { font-size:24px; margin:0 0 4px; }
h2 { font-size:18px; margin:36px 0 12px; padding-bottom:6px;
     border-bottom:1px solid var(--line); }
h3 { font-size:15px; margin:22px 0 6px; }
.sub  { color:var(--muted); margin:0 0 16px; font-size:13px; }
.panels { display:flex; flex-wrap:wrap; gap:16px; margin-bottom:8px; }
.panel  { background:var(--card); border:1px solid var(--line); border-radius:10px;
          padding:14px 16px; min-width:260px; flex:1; }
.panel h3 { margin:0 0 8px; font-size:13px; text-transform:uppercase;
            letter-spacing:.04em; color:var(--muted); }
.kv   { font-size:13px; display:grid; grid-template-columns:auto 1fr; gap:2px 14px; }
.kv .k { color:var(--muted); }
/* navigation strip */
.toc  { display:flex; flex-wrap:wrap; gap:8px; margin:0 0 28px; }
.toc a { display:inline-block; font-size:12px; padding:3px 10px; border-radius:999px;
         background:var(--card); border:1px solid var(--line); color:var(--fg);
         text-decoration:none; white-space:nowrap; }
.toc a:hover { border-color:var(--muted); }
/* charts */
.chart { width:100%; height:auto; background:var(--bg); border:1px solid var(--line);
         border-radius:10px; padding:6px; margin:8px 0 4px; }
.grid      { stroke:var(--line); stroke-width:1; }
.tick      { fill:var(--muted); font-size:10px; }
.leg       { fill:var(--fg);    font-size:11px; }
.axislabel { fill:var(--muted); font-size:11px; }
.grouplabel{ fill:var(--fg);    font-size:12px; }
.barval    { fill:var(--fg);    font-size:10px; }
.linewrap  { flex:1; min-width:380px; }
.linetitle { font-size:13px; font-weight:600; margin:6px 2px; }
/* collapsible model blocks */
details.modelblock {
  border:1px solid var(--line); border-radius:10px;
  margin:14px 0; background:var(--card);
}
details.modelblock > summary {
  cursor:pointer; list-style:none; padding:10px 14px;
  display:flex; align-items:center; gap:8px; flex-wrap:wrap; user-select:none;
}
details.modelblock > summary::-webkit-details-marker { display:none; }
details.modelblock > summary::before {
  content:'\\25B6'; font-size:10px; color:var(--muted);
  transition:transform .15s; display:inline-block; flex-shrink:0;
}
details.modelblock[open] > summary::before { transform:rotate(90deg); }
.modtitle  { font-size:15px; font-weight:600; margin:0; }
.modmeta   { font-size:12px; color:var(--muted); margin:0 0 8px;
             font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
.modelcontent { padding:0 14px 14px; }
.modelcontent .flex { display:flex; flex-wrap:wrap; gap:16px; }
/* badges */
.badge { display:inline-block; font-size:11px; padding:2px 8px; border-radius:999px;
         background:#fee2e2; color:#991b1b; margin-left:4px; vertical-align:middle;
         flex-shrink:0; }
.badge.thermal { background:#fee2e2; color:#991b1b; }
.badge.power   { background:#fef3c7; color:#92400e; }
.badge.ok      { background:#dcfce7; color:#166534; }
.badge.gpus    { background:#e0e7ff; color:#3730a3; }
@media (prefers-color-scheme: dark) {
  .badge.gpus { background:#312e81; color:#c7d2fe; }
}
.note { font-size:13px; color:var(--fg); background:var(--card);
        border:1px solid var(--line); border-radius:8px;
        padding:10px 14px; margin:8px 0 4px; }
/* tables */
.tablewrap { overflow-x:auto; }
table { border-collapse:collapse; width:100%; font-size:13px; margin-top:8px; }
th, td { text-align:right; padding:6px 10px; border-bottom:1px solid var(--line);
         white-space:nowrap; }
th:first-child, td:first-child { text-align:left; }
thead th { color:var(--muted); font-weight:600; border-bottom:2px solid var(--line); }
tbody tr:hover { background:#f3f4f6; }
td.hot  { color:#dc2626; font-weight:600; }
td.warm { color:#d97706; }
td.best { color:#16a34a; font-weight:600; }
td code { font-size:12px; }
.empty  { color:var(--muted); font-style:italic; font-size:13px; }
footer  { margin-top:40px; color:var(--muted); font-size:12px;
          border-top:1px solid var(--line); padding-top:12px; }
"""


# ----------------------------- HTML assembly ----------------------------------
def build_html(models, sys_rows, cfg_rows, generated, power_limit):
    """models: list of dicts with keys alias, table, smi (trimmed), summ,
    trim=(t0,t1,total), meta (manifest entry or {})."""
    out = [
        "<!doctype html><html lang='en'><head><meta charset='utf-8'>",
        "<meta name='viewport' content='width=device-width, initial-scale=1'>",
        "<title>llama.cpp Benchmark Report</title>",
        f"<style>{CSS}</style></head><body>",
        "<h1>llama.cpp Benchmark Report</h1>",
        f"<p class='sub'>Generated {esc(generated)} &middot; "
        f"{len(models)} model(s) with data</p>",
    ]

    # navigation strip
    nav_items = [
        ("models",         "Models"),
        ("pp-throughput",  "Prefill throughput"),
        ("tg-throughput",  "Decode throughput"),
        ("thermal-charts", "Peak thermals"),
        ("per-model-ts",   "Per-model time series"),
        ("summary-table",  "Summary table"),
    ]
    out.append("<nav class='toc'>" +
               "".join(f"<a href='#{sid}'>{esc(label)}</a>"
                       for sid, label in nav_items) +
               "</nav>")

    # info panels
    if sys_rows or cfg_rows:
        out.append("<div class='panels'>")
        if sys_rows:
            out.append("<div class='panel'><h3>System</h3><div class='kv'>")
            for k, v in sys_rows:
                out.append(f"<div class='k'>{esc(k)}</div><div>{esc(v)}</div>")
            out.append("</div></div>")
        if cfg_rows:
            out.append("<div class='panel'><h3>Benchmark configuration</h3>"
                       "<div class='kv'>")
            for k, v in cfg_rows:
                out.append(f"<div class='k'>{esc(k)}</div><div>{esc(v)}</div>")
            out.append("</div></div>")
        out.append("</div>")

    # model roster (repo + quant + placement) so the report is reproducible
    # without digging into benchmark_results.md
    if any(m["meta"] for m in models):
        out.append("<h2 id='models'>Models under test</h2>")
        out.append("<div class='tablewrap'><table><thead><tr>"
                   "<th>Alias</th>"
                   "<th style='text-align:left'>HF repo : quant</th>"
                   "<th style='text-align:left'>Placement</th>"
                   "<th style='text-align:left'>Note</th>"
                   "</tr></thead><tbody>")
        for m in models:
            meta = m["meta"]
            out.append(
                f"<tr><td>{esc(m['alias'])}</td>"
                f"<td style='text-align:left'><code>{esc(meta.get('hf') or '?')}</code></td>"
                f"<td style='text-align:left'>{esc(meta.get('placement') or '?')}</td>"
                f"<td style='text-align:left'>{esc(meta.get('note') or '')}</td></tr>"
            )
        out.append("</tbody></table></div>")

    # ------------------------------------------------------------------
    # throughput charts
    # ------------------------------------------------------------------
    pp_keys, tg_keys = {}, {}
    for m in models:
        for key, (kind, n, depth, v, sd) in m["table"].items():
            if kind == "pp" and depth == 0:
                pp_keys[key] = pretty_test(kind, n, depth)
            elif kind == "tg":
                tg_keys[key] = pretty_test(kind, n, depth)

    pp_series = sorted(pp_keys.items(), key=lambda kv: _key_n(kv[0]))
    tg_series = sorted(tg_keys.items(), key=lambda kv: (_key_depth(kv[0]),
                                                         _key_n(kv[0])))

    def items_for(series_keys):
        items = []
        for m in models:
            d = {sk: (m["table"][sk][3] if sk in m["table"] else None)
                 for sk, _ in series_keys}
            if any(v is not None for v in d.values()):
                items.append((m["alias"], d))
        return items

    out.append("<h2 id='pp-throughput'>Prefill throughput (prompt processing)</h2>")
    pp_items = items_for(pp_series)
    pp_items.sort(
        key=lambda it: max((v for v in it[1].values() if v is not None), default=0),
        reverse=True,
    )
    out.append(svg_grouped_hbars(pp_items, pp_series, PP_COLORS, "tokens / second"))

    out.append("<h2 id='tg-throughput'>Decode throughput (token generation)</h2>")
    tg_items = items_for(tg_series)
    tg_items.sort(
        key=lambda it: max((v for v in it[1].values() if v is not None), default=0),
        reverse=True,
    )
    out.append(svg_grouped_hbars(tg_items, tg_series, TG_COLORS, "tokens / second"))

    # ------------------------------------------------------------------
    # thermal summary charts — only GPUs that actually did work
    # ------------------------------------------------------------------
    gpu_indices = sorted({idx for m in models for idx, s in m["summ"].items()
                          if s["used"]})
    if not gpu_indices:  # nothing crossed the activity threshold; show all
        gpu_indices = sorted({idx for m in models for idx in m["summ"].keys()})
    temp_series = [(f"gpu{idx}", f"GPU {idx}") for idx in gpu_indices]

    def thermal_items(field):
        its = []
        for m in models:
            d = {}
            for idx in gpu_indices:
                s = m["summ"].get(idx)
                # idle GPUs are omitted so single-card runs don't show a
                # meaningless idle bar for the unused second card
                d[f"gpu{idx}"] = s[field] if (s and s["used"]) else None
            if any(v is not None for v in d.values()):
                its.append((m["alias"], d))
        return its

    out.append("<h2 id='thermal-charts'>Peak temperatures and power</h2>")
    out.append(
        "<p class='note'>Maxima are taken over the <b>active benchmark window</b> "
        "of each run; only GPUs that actively participated are shown, so "
        "single-card runs omit the idle second card. Peak power samples a few "
        "percent <b>above</b> the configured limit are normal: "
        "<code>nvidia-smi</code> reports instantaneous draw while NVIDIA's "
        "power controller regulates a time-averaged budget, so brief "
        "transients above the cap are expected and not a fault. Compare the "
        "<b>average</b> power chart against the limit instead.</p>"
    )
    out.append("<h3>Max core temperature</h3>")
    out.append(svg_grouped_hbars(thermal_items("max_temp"),
                                 temp_series, GPU_COLORS, "degrees C"))
    mem_items = thermal_items("max_memtemp")
    if any(any(v is not None for v in d.values()) for _, d in mem_items):
        out.append("<h3>Max memory temperature</h3>")
        out.append(svg_grouped_hbars(mem_items, temp_series, GPU_COLORS, "degrees C"))
    out.append("<h3>Max power draw (instantaneous samples)</h3>")
    out.append(svg_grouped_hbars(thermal_items("max_power"),
                                 temp_series, GPU_COLORS, "watts"))
    out.append("<h3>Average power draw (over the active window)</h3>")
    out.append(svg_grouped_hbars(thermal_items("avg_power"),
                                 temp_series, GPU_COLORS, "watts"))

    # ------------------------------------------------------------------
    # per-model time series (temp + power + SM clock)
    # ------------------------------------------------------------------
    out.append("<h2 id='per-model-ts'>Per-model thermals over time</h2>")
    out.append(
        "<p class='note'>Charts are trimmed to the <b>active benchmark "
        "window</b>: the model-loading phase and idle tails are cut (the "
        "amount removed is noted per model), so the measured runs fill the "
        "chart instead of being squeezed by minutes of GGUF loading. "
        "<b>SM clock</b> dropping during a run is the clearest sign of "
        "throttling. <b>Power-cap limited</b> is expected whenever the power "
        "limit is set below the card's maximum &#8212; the card is staying "
        "within budget, not faulting. <b>Thermal / HW slowdown</b> means the "
        "card hit a temperature or hardware limit and reduced clocks; "
        "investigate cooling and airflow.</p>"
    )

    any_ts = False
    for m in models:
        if not m["smi"]:
            continue
        any_ts = True
        concern, reasons = model_throttle(m["summ"])
        if concern == "thermal":
            badge = (f"<span class='badge thermal'>thermal / HW throttling"
                     f"{': ' + esc(', '.join(reasons)) if reasons else ''}</span>")
        elif concern == "power":
            badge = ("<span class='badge power'>power-cap limited "
                     "(expected at your set power limit)</span>")
        else:
            badge = "<span class='badge ok'>no throttling</span>"

        used_gpus = sorted(idx for idx, s in m["summ"].items() if s["used"])
        gpu_badge = (f"<span class='badge gpus'>GPU "
                     f"{esc(' + '.join(used_gpus))}</span>" if used_gpus else "")

        t0, t1, total = m["trim"]
        trim_note = ""
        if t0 is not None:
            cut = total - (t1 - t0)
            if cut > 1:
                trim_note = (f"showing the {t1 - t0:.0f}s active window; "
                             f"{cut:.0f}s of model-load/idle trimmed from a "
                             f"{total:.0f}s recording")

        meta = m["meta"]
        meta_line = ""
        if meta.get("hf"):
            bits = [f"-hf {meta['hf']}"]
            if meta.get("placement"):
                bits.append(f"placement: {meta['placement']}")
            meta_line = f"<p class='modmeta'>{esc(' | '.join(bits))}</p>"

        out.append(
            f"<details class='modelblock' open id='{anchor_id(m['alias'])}'>"
            f"<summary>"
            f"<span class='modtitle'>{esc(m['alias'])}</span>{gpu_badge}{badge}"
            f"</summary>"
            f"<div class='modelcontent'>{meta_line}"
        )
        if trim_note:
            out.append(f"<p class='modmeta'>{esc(trim_note)}</p>")
        out.append("<div class='flex'>")

        x_max = max(
            (r["t"] for recs in m["smi"].values() for r in recs), default=0
        )

        temp_lines  = []
        power_lines = []
        smclk_lines = []
        for idx in sorted(m["smi"].keys()):
            if used_gpus and idx not in used_gpus:
                continue   # don't plot flat idle lines for unused cards
            recs  = downsample(m["smi"][idx])
            color = (GPU_COLORS[int(idx) % len(GPU_COLORS)]
                     if idx.isdigit() else GPU_COLORS[0])
            temp_lines.append((f"GPU {idx} core", color,
                               [(r["t"], r["temp"]) for r in recs]))
            if any(r["memtemp"] is not None for r in recs):
                alt = MEMTEMP_COLOR if idx == "0" else "#9a3412"
                temp_lines.append((f"GPU {idx} mem", alt,
                                   [(r["t"], r["memtemp"]) for r in recs]))
            power_lines.append((f"GPU {idx}", color,
                                [(r["t"], r["power"]) for r in recs]))
            if any(r.get("smclk") is not None for r in recs):
                smclk_lines.append((f"GPU {idx}", color,
                                    [(r["t"], r.get("smclk")) for r in recs]))

        pl_line = (power_limit, f"{power_limit:.0f} W limit") if power_limit else None
        out.append(svg_line_chart("Temperature (°C)", temp_lines,  x_max, "°C"))
        out.append(svg_line_chart("Power draw (W)",   power_lines, x_max, "W",
                                  hline=pl_line))
        if smclk_lines:
            out.append(svg_line_chart("SM clock (MHz)", smclk_lines, x_max, "MHz"))
        out.append("</div></div></details>")

    if not any_ts:
        out.append("<p class='empty'>No nvidia-smi time-series found in the "
                   "log directory.</p>")

    # ------------------------------------------------------------------
    # summary table — pre-compute values and best-per-column for colouring
    # ------------------------------------------------------------------
    rows_data = []
    for m in models:
        tbl = m["table"]
        pp512_v  = tbl.get("pp512@d0")
        pp4096_v = tbl.get("pp4096@d0")
        tg_d0    = [t[3] for t in tbl.values() if t[0] == "tg" and t[2] == 0]
        tg_deep  = [(t[2], t[3]) for t in tbl.values() if t[0] == "tg" and t[2] > 0]
        used     = {idx: s for idx, s in m["summ"].items() if s["used"]}
        maxt_v   = [s["max_temp"]  for s in used.values() if s["max_temp"]  is not None]
        maxp_v   = [s["max_power"] for s in used.values() if s["max_power"] is not None]
        avgp_v   = [s["avg_power"] for s in used.values() if s["avg_power"] is not None]
        vram_v   = [s["max_vram"]  for s in used.values() if s["max_vram"]  is not None]

        pp512  = pp512_v[3]  if pp512_v  else None
        pp4096 = pp4096_v[3] if pp4096_v else None
        tg0    = max(tg_d0)  if tg_d0  else None
        tg16   = max((v for d, v in tg_deep if d == 16384), default=None)
        if tg16 is None and tg_deep:        # fall back to the deepest depth run
            deepest = max(d for d, _ in tg_deep)
            tg16 = max(v for d, v in tg_deep if d == deepest)
        maxt   = max(maxt_v) if maxt_v else None
        maxp   = max(maxp_v) if maxp_v else None
        # efficiency denominator: combined AVERAGE power of the GPUs that did
        # the work, over the active window — not the instantaneous peak
        avgp   = sum(avgp_v) if avgp_v else None
        vram   = (sum(vram_v) / 1024.0) if vram_v else None   # MiB -> GiB, total

        best_tg = max((v for v in (tg0, tg16) if v is not None), default=None)
        tpw = (best_tg / avgp) if (best_tg is not None and avgp and avgp > 0) else None

        concern, _ = model_throttle(m["summ"]) if m["summ"] else ("none", [])

        rows_data.append({
            "alias": m["alias"],
            "quant": (m["meta"].get("hf") or "").split(":", 1)[1]
                     if ":" in (m["meta"].get("hf") or "") else "",
            "gpus":  "+".join(sorted(used.keys())) if used else "\u2013",
            "pp512": pp512, "pp4096": pp4096,
            "tg0": tg0,     "tg16": tg16,
            "tpw": tpw,
            "avgp": avgp,   "maxp": maxp,
            "vram": vram,
            "maxt": maxt,
            "concern": concern,
        })

    # best value per throughput/efficiency column (higher = better)
    perf_cols = ["pp512", "pp4096", "tg0", "tg16", "tpw"]
    col_best = {}
    for col in perf_cols:
        vals = [r[col] for r in rows_data if r[col] is not None]
        if vals:
            col_best[col] = max(vals)

    def num_td(v, col):
        if v is None:
            return "<td>&ndash;</td>"
        css = " class='best'" if col_best.get(col) == v else ""
        return f"<td{css}>{fmt_num(v)}</td>"

    def plain_td(v):
        return f"<td>{fmt_num(v)}</td>" if v is not None else "<td>&ndash;</td>"

    def temp_td(v):
        if v is None:
            return "<td>&ndash;</td>"
        t = round(v)
        css = (" class='hot'" if t >= 85
               else (" class='warm'" if t >= 75 else ""))
        return f"<td{css}>{fmt_num(v)}</td>"

    has_quant = any(r["quant"] for r in rows_data)
    has_vram  = any(r["vram"] is not None for r in rows_data)

    out.append("<h2 id='summary-table'>Summary table</h2>")
    out.append("<div class='tablewrap'><table><thead><tr><th>Model</th>")
    if has_quant:
        out.append("<th>Quant</th>")
    out.append(
        "<th>GPUs</th>"
        "<th>Prefill 512</th><th>Prefill 4096</th>"
        "<th>Decode @d0</th><th>Decode @d16384</th>"
        "<th>t/s per W (avg)</th>"
        "<th>Avg power W</th><th>Peak power W</th>"
    )
    if has_vram:
        out.append("<th>Max VRAM GiB</th>")
    out.append("<th>Max core &deg;C</th><th>Throttled</th></tr></thead><tbody>")
    for r in rows_data:
        thr = {"thermal": "THERMAL", "power": "power cap", "none": "none"}[r["concern"]]
        out.append(f"<tr><td>{esc(r['alias'])}</td>")
        if has_quant:
            q = r["quant"] or "\u2013"
            out.append(f"<td><code>{esc(q)}</code></td>")
        out.append(
            f"<td>{esc(r['gpus'])}</td>"
            f"{num_td(r['pp512'],  'pp512')}"
            f"{num_td(r['pp4096'], 'pp4096')}"
            f"{num_td(r['tg0'],    'tg0')}"
            f"{num_td(r['tg16'],   'tg16')}"
            f"{num_td(r['tpw'],    'tpw')}"
            f"{plain_td(r['avgp'])}"
            f"{plain_td(r['maxp'])}"
        )
        if has_vram:
            out.append(plain_td(r["vram"]))
        out.append(f"{temp_td(r['maxt'])}<td>{esc(thr)}</td></tr>")
    out.append("</tbody></table></div>")

    out.append(
        "<footer>Throughput in tokens/sec (higher is better). "
        "'pp'&nbsp;=&nbsp;prefill, 'tg'&nbsp;=&nbsp;decode; "
        "'@dN' means N tokens of context were already loaded. "
        "t/s&nbsp;per&nbsp;W&nbsp;=&nbsp;best decode throughput &divide; "
        "combined <b>average</b> power of the GPUs actually used during the "
        "active window (higher is better). Peak power is the highest "
        "instantaneous sample; brief excursions above the configured limit "
        "are normal transients of NVIDIA's time-averaged power controller. "
        "The GPUs column lists the cards that did real work; single-entry "
        "rows ran on one card by design (placement <code>gpu0</code>), so "
        "single-card and dual-card rows measure different hardware budgets "
        "and are not directly comparable on absolute throughput.</footer>"
    )
    out.append("</body></html>")
    return "".join(out)


# ----------------------------- main -------------------------------------------
def main(argv=None):
    ap = argparse.ArgumentParser(
        description="Build an HTML report from benchmark logs."
    )
    ap.add_argument(
        "--logdir", default="bench_logs",
        help=(
            "directory containing <alias>.table.md and <alias>.nvidia-smi.csv files. "
            "When using the default run_benchmarks.sh layout, pass the power-limit "
            "subdirectory, e.g. --logdir bench_logs/400"
        ),
    )
    ap.add_argument(
        "--results", default=None,
        help=(
            "optional benchmark_results.md for the System/config header. "
            "Defaults to <logdir>/benchmark_results.md."
        ),
    )
    ap.add_argument(
        "--no-trim", action="store_true",
        help="disable active-window trimming; plot the full recording",
    )
    ap.add_argument("--out", default="benchmark_report.html",
                    help="output HTML path (default: benchmark_report.html)")
    args = ap.parse_args(argv)

    if not os.path.isdir(args.logdir):
        print(f"ERROR: log directory '{args.logdir}' not found.",
              file=sys.stderr)
        print("  Run run_benchmarks.sh first, or pass --logdir.", file=sys.stderr)
        return 2

    # resolve results path
    if args.results is None:
        args.results = os.path.join(args.logdir, "benchmark_results.md")

    # collect aliases
    aliases = set()
    for pat in ("*.table.md", "*.nvidia-smi.csv"):
        for path in glob.glob(os.path.join(args.logdir, pat)):
            base = os.path.basename(path)
            if base.endswith(".table.md"):
                aliases.add(base[: -len(".table.md")])
            elif base.endswith(".nvidia-smi.csv"):
                aliases.add(base[: -len(".nvidia-smi.csv")])

    if not aliases:
        print(f"ERROR: no '*.table.md' or '*.nvidia-smi.csv' files in "
              f"'{args.logdir}'.", file=sys.stderr)
        # look for power-limit subdirs to offer a hint
        try:
            subdirs = sorted(
                (d for d in os.listdir(args.logdir)
                 if os.path.isdir(os.path.join(args.logdir, d)) and d.isdigit()),
                key=int,
            )
        except OSError:
            subdirs = []
        if subdirs:
            print(f"  Found power-limit subdirectories: "
                  f"{', '.join(subdirs)}", file=sys.stderr)
            print(f"  Try: python3 make_report_html.py "
                  f"--logdir {args.logdir}/{subdirs[-1]}", file=sys.stderr)
        return 2

    manifest = parse_manifest(os.path.join(args.logdir, "manifest.tsv"))

    models = []
    for alias in sorted(aliases):
        tpath = os.path.join(args.logdir, alias + ".table.md")
        cpath = os.path.join(args.logdir, alias + ".nvidia-smi.csv")
        table = parse_table_md(tpath)  if os.path.isfile(tpath) else {}
        smi   = parse_smi_csv(cpath)   if os.path.isfile(cpath) else {}
        if args.no_trim or not smi:
            t0, t1 = None, None
            total = max((r["t"] for recs in smi.values() for r in recs),
                        default=0.0)
        else:
            t0, t1, total = active_window(smi)
        trimmed = trim_smi(smi, t0, t1)
        models.append({
            "alias": alias,
            "table": table,
            "smi":   trimmed,
            "summ":  smi_summary(trimmed),
            "trim":  (t0, t1, total),
            "meta":  manifest.get(alias, {}),
        })

    sys_rows, cfg_rows = parse_results_md_header(args.results)
    power_limit = find_power_limit(sys_rows, cfg_rows, args.logdir)
    generated = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
    html_str  = build_html(models, sys_rows, cfg_rows, generated, power_limit)

    with open(args.out, "w", encoding="utf-8") as fh:
        fh.write(html_str)

    n_tp = sum(1 for m in models if m["table"])
    n_th = sum(1 for m in models if m["smi"])
    print(f"Wrote {args.out}")
    print(f"  models with throughput data: {n_tp}")
    print(f"  models with thermal data:    {n_th}")
    return 0


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