#!/usr/bin/env python3
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4

"""Summarize disk read-job latency over time from a libtorrent session-stats
log (the file written by ``client_test -O``, the same format
parse_session_stats.py reads).

libtorrent records read-job latency (queue wait + execution + completion-queue
wait, measured on the network thread) as a histogram of monotonic counters
named ``disk.disk_read_latency1`` .. ``disk.disk_read_latency20``, linear in
30 ms steps: ``disk_read_latencyN`` counts read jobs whose latency fell in
[30*(N-1), 30*N) ms, and the last counter (latency20) is the catch-all for
>= 570 ms. These are only populated in disk-latency-stats builds; without that
build flag the buckets stay zero and there is nothing to plot.

This tool diffs the cumulative buckets between samples, groups the resulting
per-job counts into fixed wall-clock intervals (2 s by default), and from each
interval's histogram estimates the median, mean, and 95th-percentile latency,
reported in milliseconds. collect_latency() returns those series plus a single
representative number for the whole run -- the peak interval p95 -- which
run_benchmark.py drops into its results table. It also keeps the full
per-interval histograms, which plot_latency() renders as a time x latency
heat-map (cell color = number of read jobs in that time/latency bin).
"""

from argparse import ArgumentParser
from collections.abc import Iterator
from dataclasses import dataclass
from dataclasses import field
from pathlib import Path
import re

import plot_layout

# number of disk_read_latency buckets exposed by libtorrent (see
# performance_counters.hpp: disk_read_latency1 .. disk_read_latency20), linear
# in 30 ms steps.
NUM_BUCKETS = 20
# width of each linear bucket, in milliseconds
BUCKET_STEP_MS = 30
# counter-name prefix in the session-stats header (category "disk"); the
# counters are 1-indexed (latency1 .. latency20), so the first index is 1.
BUCKET_PREFIX = "disk.disk_read_latency"
BUCKET_FIRST = 1
# default wall-clock width, in seconds, of the intervals the per-job counts are
# grouped into (one heat-map column / one latency-stats sample per interval).
DEFAULT_INTERVAL_S = 2.0

# leading "[<ms>]" timestamp client_test writes on every stats line
_TS_RE = re.compile(r"^\[(\d+)\]")


def bucket_rep_ms(i: int) -> float:
    """Representative latency, in milliseconds, for histogram bucket `i`
    (0-based; bucket i is the counter disk_read_latency{i+1}).

    Bucket i covers [30*i, 30*(i+1)) ms, so we use its midpoint, 30*i + 15 ms.
    For the open-ended top bucket this is just the midpoint of its first 30 ms
    step, an approximate lower bound.
    """
    return BUCKET_STEP_MS * i + BUCKET_STEP_MS / 2.0


@dataclass
class LatencySeries:
    """Per-interval disk read-latency summary plus a single run-level number."""

    # interval midpoint, in seconds since the start of the run
    times: list[float] = field(default_factory=list)
    # per-interval latency statistics, in milliseconds
    median_ms: list[float] = field(default_factory=list)
    mean_ms: list[float] = field(default_factory=list)
    p95_ms: list[float] = field(default_factory=list)
    # number of read jobs observed in each interval
    counts: list[int] = field(default_factory=list)
    # representative latency for the whole run (ms): the worst (peak) interval
    # p95. 0.0 when no read-latency samples were recorded -- e.g. a build
    # without disk-latency-stats, or the posix backend (no disk job queue).
    peak_p95_ms: float = 0.0

    # dense time x latency histogram for the heat-map. hist_grid[col] is the
    # NUM_BUCKETS-long bucket histogram for one interval; columns are contiguous
    # (zero-filled where an interval had no read jobs), spanning from the first
    # to the last interval that contained a sample. grid_start_s is the left
    # (earliest) edge of column 0, in seconds; interval_s is the column width.
    hist_grid: list[list[int]] = field(default_factory=list)
    grid_start_s: float = 0.0
    interval_s: float = DEFAULT_INTERVAL_S


def _parse_samples(path: Path) -> Iterator[tuple[float, list[int]]]:
    """Yield (time_s, counts) for each session-stats sample in `path`, where
    counts is a list of the NUM_BUCKETS cumulative latency-bucket values. The
    leading "[<ms>]" timestamp gives wall-clock time; without it we fall back
    to the sample index (assuming 1 s spacing). Yields nothing if the file is
    unreadable or its header has no latency columns.
    """
    try:
        f = open(path, encoding="utf-8", errors="replace")
    except OSError:
        return
    with f:
        # find the header and locate the latency columns
        cols = None
        for line in f:
            if "session stats header:" in line:
                keys = line.split("session stats header:")[1].strip().split(", ")
                cols = []
                for n in range(NUM_BUCKETS):
                    try:
                        cols.append(keys.index(f"{BUCKET_PREFIX}{BUCKET_FIRST + n}"))
                    except ValueError:
                        return  # this log has no latency columns
                break
        if not cols:
            return

        idx = 0
        for line in f:
            if "session stats (" not in line:
                continue
            try:
                values = line.split(" values): ")[1].strip().split(", ")
                counts = [int(values[c]) for c in cols]
            except (IndexError, ValueError):
                continue
            m = _TS_RE.match(line)
            t_s = (int(m.group(1)) / 1000.0) if m else float(idx)
            idx += 1
            yield t_s, counts


def _percentile(hist: list[int], frac: float, total: int) -> float:
    """The `frac` quantile (e.g. 0.95) of a bucketed histogram, returned as the
    representative latency (ms) of the bucket the quantile falls in.
    """
    threshold = frac * total
    cum = 0
    for i, c in enumerate(hist):
        cum += c
        if cum >= threshold:
            return bucket_rep_ms(i)
    return bucket_rep_ms(len(hist) - 1)


def collect_latency(
    path: str | Path, interval_s: float = DEFAULT_INTERVAL_S
) -> LatencySeries:
    """Parse `path` and return a LatencySeries: per-interval median/mean/p95
    disk read latency (ms) over `interval_s`-second wall-clock windows, plus
    the peak interval p95 as a single representative number for the run.

    The buckets are cumulative counters, so each sample contributes its delta
    from the previous sample; those deltas are accumulated into the interval
    the sample falls in. The first sample is taken as a baseline (counters may
    already be nonzero when logging starts, and attributing that backlog to
    interval 0 would distort it).
    """
    by_interval: dict[int, list[int]] = {}
    prev: list[int] | None = None
    for t_s, counts in _parse_samples(Path(path)):
        if prev is None:
            prev = counts
            continue
        # guard against counter resets / out-of-order lines with max(0, .)
        delta = [max(0, counts[i] - prev[i]) for i in range(NUM_BUCKETS)]
        prev = counts
        acc = by_interval.setdefault(int(t_s // interval_s), [0] * NUM_BUCKETS)
        for i in range(NUM_BUCKETS):
            acc[i] += delta[i]

    series = LatencySeries()
    series.interval_s = interval_s
    for k in sorted(by_interval):
        hist = by_interval[k]
        n = sum(hist)
        if n == 0:
            continue
        p95 = _percentile(hist, 0.95, n)
        series.times.append((k + 0.5) * interval_s)
        series.median_ms.append(_percentile(hist, 0.5, n))
        series.mean_ms.append(sum(c * bucket_rep_ms(i) for i, c in enumerate(hist)) / n)
        series.p95_ms.append(p95)
        series.counts.append(n)
        series.peak_p95_ms = max(series.peak_p95_ms, p95)

    # build the dense, contiguous time x latency grid for the heat-map: one
    # column per interval from the first to the last that saw a sample, with
    # empty intervals zero-filled so the time axis stays linear.
    if by_interval:
        k_min, k_max = min(by_interval), max(by_interval)
        series.grid_start_s = k_min * interval_s
        for k in range(k_min, k_max + 1):
            series.hist_grid.append(by_interval.get(k, [0] * NUM_BUCKETS))
    return series


def plot_latency(
    series: LatencySeries, out_path: Path, title: str = "disk read latency"
) -> bool:
    """Plot read latency over time as a heat-map to `out_path` (PNG): x-axis is
    time, y-axis is latency, and each cell's color is the number of read jobs
    that fell in that (time interval, latency bucket). Returns True on success,
    False if matplotlib is missing or there is no data to plot (so callers can
    skip referencing the image).
    """
    if not series.hist_grid:
        return False
    try:
        import matplotlib

        matplotlib.use("Agg")  # headless backend; no display required
        import matplotlib.colors as mcolors
        import matplotlib.pyplot as plt
        import numpy as np
    except ImportError:
        return False

    # Z[bucket, interval] = job count. hist_grid is interval-major, so transpose
    # to put latency buckets (y) on rows and time intervals (x) on columns.
    z = np.array(series.hist_grid, dtype=float).T
    if z.sum() == 0:
        return False
    n_cols = z.shape[1]

    # cell edges for pcolormesh. The y buckets are linear in BUCKET_STEP_MS; the
    # open-ended top bucket is drawn as one more step for display.
    x_edges = series.grid_start_s + np.arange(n_cols + 1) * series.interval_s
    y_edges = BUCKET_STEP_MS * np.arange(NUM_BUCKETS + 1)

    # mask empty cells so they render as the colormap's lowest color. counts
    # span orders of magnitude, so use a log color scale (falling back to linear
    # when all counts are equal).
    z_masked = np.ma.masked_equal(z, 0.0)
    vmin = z_masked.min()
    vmax = z_masked.max()
    norm = mcolors.LogNorm(vmin=vmin, vmax=vmax) if vmax > vmin else None
    # a light-to-dark palette: a low count sits near the light background and
    # the color deepens as the job count grows. Empty cells take the palette's
    # lowest color so "no jobs" blends continuously into "a few jobs".
    cmap = plt.get_cmap("Blues").copy()
    cmap.set_bad(cmap(0.0))

    # 1200 px wide at dpi=100 to match the vmstat / piece-pass-order plots on
    # the summary page (they are 1200 px wide, and all are embedded with
    # max-width:100%, so a narrower image renders smaller alongside them).
    fig, ax = plt.subplots(figsize=(12.0, 4.0))
    mesh = ax.pcolormesh(x_edges, y_edges, z_masked, cmap=cmap, norm=norm)

    # pin the heat-map box to the shared summary-page margins (see plot_layout)
    # so it lines up with the other plots, and put the colorbar in a thin
    # dedicated axes inside the reserved right margin -- a normal
    # fig.colorbar(ax=ax) would steal width from the heat-map and make this
    # box narrower than the rest.
    box_bottom, box_top = 0.16, 0.90
    fig.subplots_adjust(
        left=plot_layout.BOX_LEFT,
        right=plot_layout.BOX_RIGHT,
        bottom=box_bottom,
        top=box_top,
    )
    cax = fig.add_axes(
        [
            plot_layout.BOX_RIGHT + 0.02,
            box_bottom,
            0.018,
            box_top - box_bottom,
        ]
    )
    cbar = fig.colorbar(mesh, cax=cax)
    cbar.set_label("read jobs")
    ax.set_ylim(bottom=0)
    ax.set_xlabel("time (s)")
    ax.set_ylabel("read latency (ms)")
    ax.set_title(title)
    fig.savefig(out_path, dpi=100)
    plt.close(fig)
    return True


def main(
    input_file: str | Path,
    output_dir: str | Path,
    interval_s: float = DEFAULT_INTERVAL_S,
) -> float:
    """Parse `input_file`, write disk_read_latency.png into `output_dir`, and
    return the run's representative latency (peak interval p95, ms).
    """
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)
    series = collect_latency(input_file, interval_s)
    out_png = output_dir / "disk_read_latency.png"
    if plot_latency(series, out_png):
        print(f"wrote {out_png}")
    elif not series.times:
        print(
            "no disk read-latency samples found" " (needs a disk-latency-stats build)"
        )
    else:
        print("matplotlib not installed; skipped latency plot")
    print(f"peak interval p95 read latency: {series.peak_p95_ms:.3f} ms")
    return series.peak_p95_ms


if __name__ == "__main__":
    p = ArgumentParser(description=__doc__)
    p.add_argument(
        "input",
        type=Path,
        help="session-stats log to parse (client_test -O output)",
    )
    p.add_argument(
        "--output-dir",
        type=Path,
        default=Path("."),
        help="directory to write disk_read_latency.png to",
    )
    p.add_argument(
        "--interval",
        type=float,
        default=DEFAULT_INTERVAL_S,
        help=f"bucket interval in seconds (default: {DEFAULT_INTERVAL_S:g})",
    )
    args = p.parse_args()
    main(args.input, args.output_dir, args.interval)
