#!/usr/bin/env python3
"""Aggregate MB5 sweep data into cross-config comparison figures.

Reads a sweep root (e.g. /home/admin/.../mb5_runs/) and a tag
(e.g. "20260527_164040"). For each (config, rep) tuple, loads:

  ${tag}_${config}_rep${N}/replay_metrics.summary.json     -> aggregate stats
  ${tag}_${config}_rep${N}/replay_metrics.jsonl            -> per-request latency
  ${tag}_${config}_rep${N}_${config}/kv_snapshots/         -> per-instance KV state

Produces, in --out-dir:
  mb5_kv_timeline.png       — 4 panels, cluster-wide KV utilization over time
                              (1 faint line per rep + bold median across reps)
  mb5_peak_utilization.png  — bar chart: peak / steady KV util per config
                              (mean across reps + error bars)
  mb5_latency_compare.png   — bar chart: p50 / p90 / p99 e2e latency per config
  mb5_summary.csv           — flat table for the writeup

Use case:
  python aggregate_mb5.py --sweep-root /home/.../mb5_runs \\
                           --tag 20260527_164040 \\
                           --configs "8C 6P+2D 4P+4D 2P+6D" \\
                           --reps 3 \\
                           --out-dir figs/mb5
"""
from __future__ import annotations

import argparse
import csv
import json
from collections import defaultdict
from pathlib import Path

import numpy as np

# matplotlib is imported lazily inside the plot functions so the --reduce
# path (numpy-only) can run on a serving host without matplotlib installed.


def load_snapshots_for_run(snap_dir: Path) -> list[dict]:
    """Merge all per-PID snapshot files in snap_dir, tag with pid, sort by t_unix."""
    out = []
    for f in sorted(snap_dir.glob("mb5_kv_snapshot_pid*.jsonl")):
        pid = int(f.stem.replace("mb5_kv_snapshot_pid", ""))
        with f.open() as fh:
            for line in fh:
                line = line.strip()
                if not line:
                    continue
                try:
                    d = json.loads(line)
                except json.JSONDecodeError:
                    continue
                d["pid"] = pid
                out.append(d)
    out.sort(key=lambda d: d["t_unix"])
    return out


def load_pid_roles(logs_dir: Path) -> dict[int, str]:
    """Map EngineCore PID -> 'P' | 'D' | 'C' by parsing vllm_logs filenames.

    File names look like vllm_idx{i}_gpu{g}_kv_{producer|consumer|both}.log and
    each contains '(EngineCore pid=NNNN)'. Returns {} if no logs found.
    """
    role_map = {"producer": "P", "consumer": "D", "both": "C"}
    out: dict[int, str] = {}
    if not logs_dir.is_dir():
        return out
    for f in logs_dir.glob("vllm_idx*_kv_*.log"):
        role = None
        for key, short in role_map.items():
            if f.name.endswith(f"kv_{key}.log"):
                role = short
                break
        if role is None:
            continue
        with f.open(errors="ignore") as fh:
            for line in fh:
                if "EngineCore pid=" in line:
                    try:
                        pid = int(line.split("EngineCore pid=")[1].split(")")[0].split()[0])
                        out[pid] = role
                        break
                    except (ValueError, IndexError):
                        continue
    return out


def cluster_timeline(snaps: list[dict], bin_size_s: float = 1.0,
                     keep_pids: set | None = None,
                     t0: float | None = None,
                     n_bins: int | None = None) -> tuple[np.ndarray, ...]:
    """Bin per-PID snapshots into a cluster-wide timeline.

    For each time bin, sum used_blocks across PIDs that emitted a snapshot
    in that bin. PIDs without a sample in a bin carry their previous value
    forward (so a quiet PID doesn't artificially drop the total).

    If keep_pids is given, only those PIDs are counted (used for per-role
    P-pool / D-pool splits); the pool ceiling is summed over the same subset.
    Pass a shared t0/n_bins so role-splits land on the same time grid.
    """
    if keep_pids is not None:
        snaps = [s for s in snaps if s["pid"] in keep_pids]
    if not snaps:
        empty = np.array([], dtype=float)
        return empty, empty, empty, empty, empty
    if t0 is None:
        t0 = snaps[0]["t_unix"]
    if n_bins is None:
        t_end = snaps[-1]["t_unix"]
        n_bins = max(1, int(np.ceil((t_end - t0) / bin_size_s)) + 1)
    times = np.arange(n_bins) * bin_size_s

    pids = sorted({s["pid"] for s in snaps})
    pid_to_idx = {pid: i for i, pid in enumerate(pids)}

    # last-known used/total/waiting per PID at each bin (carry-forward)
    used = np.zeros((len(pids), n_bins), dtype=np.int64)
    waiting = np.zeros((len(pids), n_bins), dtype=np.int64)
    running = np.zeros((len(pids), n_bins), dtype=np.int64)
    total_per_pid = np.zeros(len(pids), dtype=np.int64)

    last_used = [0] * len(pids)
    last_waiting = [0] * len(pids)
    last_running = [0] * len(pids)

    snap_iter = iter(snaps)
    next_snap = next(snap_iter, None)

    for b in range(n_bins):
        t_lo = t0 + b * bin_size_s
        t_hi = t_lo + bin_size_s
        while next_snap is not None and next_snap["t_unix"] < t_hi:
            i = pid_to_idx[next_snap["pid"]]
            last_used[i] = next_snap.get("used_blocks", 0)
            last_waiting[i] = len(next_snap.get("waiting", []))
            last_running[i] = len(next_snap.get("running", []))
            total_per_pid[i] = next_snap.get("total_blocks", 0)
            next_snap = next(snap_iter, None)
        for i in range(len(pids)):
            used[i, b] = last_used[i]
            waiting[i, b] = last_waiting[i]
            running[i, b] = last_running[i]

    total_used = used.sum(axis=0)
    total_pool = int(total_per_pid.sum())
    total_waiting = waiting.sum(axis=0)
    total_running = running.sum(axis=0)
    pool_frac = total_used / max(total_pool, 1)
    return times, total_used, pool_frac, total_waiting, total_running


def load_summary(rundir: Path) -> dict | None:
    p = rundir / "replay_metrics.summary.json"
    if not p.is_file():
        return None
    return json.loads(p.read_text())


def _steady_median(arr: np.ndarray) -> float:
    n = len(arr)
    if n == 0:
        return 0.0
    if n >= 10:
        return float(np.median(arr[int(n * 0.1):int(n * 0.9)]))
    return float(np.median(arr))


def per_run_metrics(snaps_dir: Path, rundir: Path) -> dict:
    snaps = load_snapshots_for_run(snaps_dir)
    summary = load_summary(rundir) or {}

    # Establish a shared time grid (global t0 / n_bins) so the overall and
    # per-role timelines all line up on the same x axis.
    if snaps:
        t0 = snaps[0]["t_unix"]
        t_end = snaps[-1]["t_unix"]
        n_bins = max(1, int(np.ceil(t_end - t0)) + 1)
    else:
        t0, n_bins = None, None

    times, total_used, pool_frac, total_waiting, total_running = cluster_timeline(
        snaps, t0=t0, n_bins=n_bins
    )
    n = len(times)

    out = {
        "times": times.tolist(),
        "total_used": total_used.tolist(),
        "pool_frac": pool_frac.tolist(),
        "total_waiting": total_waiting.tolist(),
        "total_running": total_running.tolist(),
        "peak_pool_frac": float(pool_frac.max()) if n else 0.0,
        "steady_pool_frac": _steady_median(pool_frac),
        "peak_waiting": int(total_waiting.max()) if n else 0,
        "summary": summary,
    }

    # Per-role (P-pool vs D-pool) split for PD configs.
    roles = load_pid_roles(snaps_dir.parent / "vllm_logs")
    p_pids = {pid for pid, r in roles.items() if r == "P"}
    d_pids = {pid for pid, r in roles.items() if r == "D"}
    if p_pids and d_pids:
        for tag, subset in (("p", p_pids), ("d", d_pids)):
            _, _, frac, _, run = cluster_timeline(
                snaps, keep_pids=subset, t0=t0, n_bins=n_bins
            )
            out[f"{tag}_pool_frac"] = frac.tolist()
            out[f"{tag}_running"] = run.tolist()
            out[f"{tag}_peak_frac"] = float(frac.max()) if len(frac) else 0.0
            out[f"{tag}_steady_frac"] = _steady_median(frac)
    return out


def collect_sweep(sweep_root: Path, tag: str, configs: list[str], reps: int) -> dict:
    """Returns {config: [run_record_per_rep]}."""
    out: dict[str, list[dict]] = defaultdict(list)
    for config in configs:
        for rep in range(1, reps + 1):
            rundir = sweep_root / f"{tag}_{config}_rep{rep}"
            snap_dir = sweep_root / f"{tag}_{config}_rep{rep}_{config}/kv_snapshots"
            if not snap_dir.is_dir():
                print(f"[agg] MISSING: {snap_dir}")
                continue
            metrics = per_run_metrics(snap_dir, rundir)
            metrics["rep"] = rep
            out[config].append(metrics)
            print(
                f"[agg] {config} rep{rep}: peak={metrics['peak_pool_frac']:.1%} "
                f"steady={metrics['steady_pool_frac']:.1%} "
                f"peak_wait={metrics['peak_waiting']}"
            )
    return out


def plot_kv_timeline(sweep: dict, out: Path) -> None:
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    n_configs = len(sweep)
    if n_configs == 0:
        return
    fig, axes = plt.subplots(n_configs, 1, figsize=(14, 2.5 * n_configs), sharex=True)
    if n_configs == 1:
        axes = [axes]
    for ax, (config, reps) in zip(axes, sweep.items()):
        for rep_data in reps:
            t = np.asarray(rep_data["times"])
            ax.plot(t, np.asarray(rep_data["pool_frac"]) * 100, alpha=0.4, lw=1.0,
                    label=f"rep{rep_data['rep']}")
        # bold median across reps (need to align times — use longest series)
        if reps:
            max_len = max(len(r["times"]) for r in reps)
            arr = np.full((len(reps), max_len), np.nan)
            for i, r in enumerate(reps):
                arr[i, :len(r["pool_frac"])] = r["pool_frac"]
            median = np.nanmedian(arr, axis=0) * 100
            ax.plot(np.arange(max_len), median, color="#222", lw=2.0, label="median")
        ax.axhline(90, color="#c44e52", ls="--", alpha=0.6, lw=1, label="90%")
        ax.set_ylim(0, 105)
        ax.set_ylabel(f"{config}\ncluster KV (%)")
        ax.grid(True, alpha=0.3)
        ax.legend(loc="upper right", fontsize=8)
    axes[-1].set_xlabel("wall-clock since first snapshot (s)")
    fig.suptitle("MB5: cluster-wide KV pool utilization over time", fontsize=12)
    fig.tight_layout()
    out.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(out, dpi=120)
    plt.close(fig)
    print(f"wrote {out}")


def plot_peak_utilization(sweep: dict, out: Path) -> None:
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    configs = list(sweep.keys())
    peaks = [[r["peak_pool_frac"] * 100 for r in sweep[c]] for c in configs]
    steady = [[r["steady_pool_frac"] * 100 for r in sweep[c]] for c in configs]
    peak_means = [np.mean(p) if p else 0 for p in peaks]
    peak_std = [np.std(p) if len(p) > 1 else 0 for p in peaks]
    steady_means = [np.mean(s) if s else 0 for s in steady]
    steady_std = [np.std(s) if len(s) > 1 else 0 for s in steady]

    x = np.arange(len(configs))
    width = 0.35

    fig, ax = plt.subplots(figsize=(9, 4.5))
    ax.bar(x - width/2, peak_means, width, yerr=peak_std, label="peak",
           color="#c44e52", capsize=4)
    ax.bar(x + width/2, steady_means, width, yerr=steady_std, label="steady (10–90%)",
           color="#4c72b0", capsize=4)
    ax.axhline(90, color="#444", ls="--", alpha=0.5, lw=1, label="90% red line")
    ax.set_xticks(x)
    ax.set_xticklabels(configs)
    ax.set_ylabel("Cluster KV pool utilization (%)")
    ax.set_ylim(0, 105)
    ax.set_title("MB5: KV pool pressure — peak vs steady-state")
    ax.legend(loc="upper left", fontsize=9)
    ax.grid(True, axis="y", alpha=0.3)
    fig.tight_layout()
    fig.savefig(out, dpi=120)
    plt.close(fig)
    print(f"wrote {out}")


def plot_latency_compare(sweep: dict, out: Path) -> None:
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    configs = list(sweep.keys())
    metrics = ["p50", "p90", "p99"]
    data = {m: [] for m in metrics}
    for c in configs:
        for m in metrics:
            vals = []
            for r in sweep[c]:
                s = r["summary"].get("latency_stats_s")
                if s and s.get(m) is not None:
                    vals.append(s[m])
            data[m].append(np.mean(vals) if vals else 0.0)

    x = np.arange(len(configs))
    width = 0.25
    colors = {"p50": "#4c72b0", "p90": "#dd8452", "p99": "#c44e52"}
    fig, ax = plt.subplots(figsize=(9, 4.5))
    for i, m in enumerate(metrics):
        ax.bar(x + (i - 1) * width, data[m], width, label=m, color=colors[m])
    ax.set_xticks(x)
    ax.set_xticklabels(configs)
    ax.set_ylabel("End-to-end latency (s)")
    ax.set_title("MB5: e2e latency by PD configuration")
    ax.legend()
    ax.grid(True, axis="y", alpha=0.3)
    fig.tight_layout()
    fig.savefig(out, dpi=120)
    plt.close(fig)
    print(f"wrote {out}")


def plot_role_split(sweep: dict, out: Path) -> None:
    """For PD configs, show P-pool vs D-pool KV % over time (rep1) — exposes
    the imbalance that the cluster average hides. 8C (no role split) shows
    the overall cluster line for reference."""
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    n_configs = len(sweep)
    if n_configs == 0:
        return
    fig, axes = plt.subplots(n_configs, 1, figsize=(14, 2.6 * n_configs), sharex=True)
    if n_configs == 1:
        axes = [axes]
    for ax, (config, reps) in zip(axes, sweep.items()):
        if not reps:
            continue
        r = reps[0]  # rep1
        t = np.asarray(r["times"])
        if "p_pool_frac" in r and "d_pool_frac" in r:
            ax.plot(t, np.asarray(r["p_pool_frac"]) * 100, color="#4c72b0",
                    lw=1.5, label="P-pool (prefill)")
            ax.plot(t, np.asarray(r["d_pool_frac"]) * 100, color="#c44e52",
                    lw=1.5, label="D-pool (decode)")
            ax.plot(t, np.asarray(r["pool_frac"]) * 100, color="#999",
                    lw=1.0, ls=":", label="cluster avg")
        else:
            ax.plot(t, np.asarray(r["pool_frac"]) * 100, color="#222",
                    lw=1.5, label="cluster (kv_both)")
        ax.axhline(90, color="#444", ls="--", alpha=0.5, lw=1)
        ax.set_ylim(0, 105)
        ax.set_ylabel(f"{config}\nKV pool (%)")
        ax.grid(True, alpha=0.3)
        ax.legend(loc="upper right", fontsize=8, ncol=3)
    axes[-1].set_xlabel("wall-clock since first snapshot (s)")
    fig.suptitle("MB5: per-role KV pool utilization (P-pool vs D-pool), rep1",
                 fontsize=12)
    fig.tight_layout()
    out.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(out, dpi=120)
    plt.close(fig)
    print(f"wrote {out}")


def write_summary_csv(sweep: dict, out: Path) -> None:
    rows = []
    for config, reps in sweep.items():
        for r in reps:
            s = r["summary"]
            lat = s.get("latency_stats_s") or {}
            ttft = s.get("ttft_stats_s") or {}
            rows.append({
                "config": config,
                "rep": r["rep"],
                "n_requests": s.get("request_count"),
                "n_success": s.get("success_count"),
                "wall_clock_s": s.get("wall_clock_s"),
                "peak_pool_frac": r["peak_pool_frac"],
                "steady_pool_frac": r["steady_pool_frac"],
                "p_pool_peak_frac": r.get("p_peak_frac"),
                "p_pool_steady_frac": r.get("p_steady_frac"),
                "d_pool_peak_frac": r.get("d_peak_frac"),
                "d_pool_steady_frac": r.get("d_steady_frac"),
                "peak_waiting": r["peak_waiting"],
                "latency_p50_s": lat.get("p50"),
                "latency_p90_s": lat.get("p90"),
                "latency_p99_s": lat.get("p99"),
                "ttft_p50_s": ttft.get("p50"),
                "ttft_p90_s": ttft.get("p90"),
                "ttft_p99_s": ttft.get("p99"),
                "prefix_cache_hit_ratio": s.get("prefix_cache_hit_ratio"),
            })
    if not rows:
        print("[agg] no rows; skipping CSV")
        return
    out.parent.mkdir(parents=True, exist_ok=True)
    with out.open("w", newline="") as fh:
        w = csv.DictWriter(fh, fieldnames=list(rows[0].keys()))
        w.writeheader()
        w.writerows(rows)
    print(f"wrote {out} ({len(rows)} rows)")


def render_all(sweep: dict, out_dir: Path) -> None:
    plot_kv_timeline(sweep, out_dir / "mb5_kv_timeline.png")
    plot_role_split(sweep, out_dir / "mb5_role_split.png")
    plot_peak_utilization(sweep, out_dir / "mb5_peak_utilization.png")
    plot_latency_compare(sweep, out_dir / "mb5_latency_compare.png")
    write_summary_csv(sweep, out_dir / "mb5_summary.csv")


def main() -> None:
    p = argparse.ArgumentParser(
        description="MB5 aggregate. Two-stage: --reduce (numpy-only, runs on "
                    "a serving host) dumps a compact JSON; --from-reduced "
                    "(needs matplotlib) renders figures locally. Or run "
                    "directly (raw snapshots -> figures) when both the data "
                    "and matplotlib are local."
    )
    p.add_argument("--sweep-root", type=Path,
                   help="dir containing ${tag}_${config}_rep${N}/ subdirs")
    p.add_argument("--tag")
    p.add_argument("--configs", default="8C 6P+2D 4P+4D 2P+6D",
                   help="space-separated config names")
    p.add_argument("--reps", type=int, default=3)
    p.add_argument("--out-dir", type=Path, default=Path("figs/mb5"))
    p.add_argument("--reduce-to", type=Path,
                   help="numpy-only: write reduced sweep JSON here and exit "
                        "(no plotting, no matplotlib needed)")
    p.add_argument("--from-reduced", type=Path,
                   help="load a reduced sweep JSON (from --reduce-to) and "
                        "render figures into --out-dir")
    args = p.parse_args()

    if args.from_reduced:
        sweep = json.loads(args.from_reduced.read_text())
        render_all(sweep, args.out_dir)
        return

    if not (args.sweep_root and args.tag):
        p.error("--sweep-root and --tag are required unless --from-reduced is given")

    configs = args.configs.split()
    sweep = collect_sweep(args.sweep_root, args.tag, configs, args.reps)

    if args.reduce_to:
        args.reduce_to.parent.mkdir(parents=True, exist_ok=True)
        args.reduce_to.write_text(json.dumps(sweep))
        print(f"wrote reduced sweep -> {args.reduce_to}")
        return

    render_all(sweep, args.out_dir)


if __name__ == "__main__":
    main()
