#!/usr/bin/env python3
"""Combine two hyperfine JSON files and calculate pooled timing statistics.

Percentiles use Hyndman-Fan type 7: h = (n - 1) * p, with linear
interpolation between the observations at floor(h) and ceil(h).
"""

import json
import math
import statistics
import sys


EXCLUDED = {"C# AngleSharp JIT", "C# AngleSharp NativeAOT"}
CONTROL = "xmllint DOM"


def load(path):
    with open(path, encoding="utf-8") as stream:
        results = json.load(stream)["results"]
    rows = {row["command"]: row["times"] for row in results}
    if len(rows) != len(results):
        raise ValueError(f"duplicate command in {path}")
    if any(not times for times in rows.values()):
        raise ValueError(f"empty times array in {path}")
    return rows


def quantile_type7(ordered, probability):
    position = (len(ordered) - 1) * probability
    lower = math.floor(position)
    upper = math.ceil(position)
    weight = position - lower
    return ordered[lower] * (1 - weight) + ordered[upper] * weight


def summarize(command, times):
    ordered = sorted(times)
    return {
        "command": command,
        "observations": len(ordered),
        "min_seconds": ordered[0],
        "mean_seconds": statistics.mean(ordered),
        "median_seconds": statistics.median(ordered),
        "p90_seconds": quantile_type7(ordered, 0.90),
        "p95_seconds": quantile_type7(ordered, 0.95),
        "p99_seconds": quantile_type7(ordered, 0.99),
        "p99_9_seconds": quantile_type7(ordered, 0.999),
        "sd_seconds": statistics.stdev(ordered),
    }


def combine(forward, reverse):
    if forward.keys() != reverse.keys():
        raise ValueError("forward and reverse command sets differ")
    run_counts = {len(times) for times in (*forward.values(), *reverse.values())}
    if len(run_counts) != 1:
        raise ValueError("all commands and orders must have the same run count")
    runs_per_order = run_counts.pop()
    if runs_per_order < 1:
        raise ValueError("at least one run per order is required")

    rows = [summarize(command, forward[command] + reverse[command]) for command in forward]
    try:
        control = next(row for row in rows if row["command"] == CONTROL)
    except StopIteration as error:
        raise ValueError(f"missing control command: {CONTROL}") from error

    excluded = [
        row | {"reason": "accepts XML 1.0 forbidden U+0001"}
        for row in rows
        if row["command"] in EXCLUDED
    ]
    if {row["command"] for row in excluded} != EXCLUDED:
        raise ValueError("missing one or more expected AngleSharp commands")

    ranked = sorted(
        (row for row in rows if row["command"] != CONTROL and row["command"] not in EXCLUDED),
        key=lambda row: row["mean_seconds"],
    )
    for rank, row in enumerate(ranked, 1):
        row["rank"] = rank

    return {
        "methodology": {
            "input_observations_per_command": runs_per_order * 2,
            "warmups_per_order": 10,
            "runs_per_order": runs_per_order,
            "orders": ["forward", "reverse"],
            "ranking": "ascending arithmetic mean over pooled raw times",
            "quantiles": (
                "Hyndman-Fan type 7 linear interpolation: "
                "h=(n-1)*p between x[floor(h)] and x[ceil(h)]"
            ),
            "p99_9_note": (
                "empirical tail estimate; only N*0.001 observations are expected above p99.9"
            ),
            "standard_deviation": "sample SD (n-1) over pooled raw times",
            "outliers": "retained",
            "shell": "none, except the stdin-only xmltodict command",
        },
        "ranked": ranked,
        "excluded": excluded,
        "control": control,
    }


def self_check():
    sample = [0.0, 10.0, 20.0, 30.0, 40.0]
    assert quantile_type7(sample, 0.5) == 20.0
    assert quantile_type7(sample, 0.9) == 36.0
    assert math.isclose(quantile_type7([0.0, 10.0], 0.999), 9.99)

    forward = {
        "fast": [1.0, 2.0],
        "slow": [3.0, 4.0],
        CONTROL: [2.0, 3.0],
        "C# AngleSharp JIT": [5.0, 6.0],
        "C# AngleSharp NativeAOT": [4.0, 5.0],
    }
    result = combine(forward, forward)
    assert [row["command"] for row in result["ranked"]] == ["fast", "slow"]
    assert [row["rank"] for row in result["ranked"]] == [1, 2]
    assert result["methodology"]["input_observations_per_command"] == 4
    assert len(result["excluded"]) == 2
    assert result["control"]["command"] == CONTROL


def main(argv):
    if argv == ["--self-check"]:
        self_check()
        print("self-check: ok")
        return
    if len(argv) != 3:
        raise SystemExit(f"usage: {sys.argv[0]} FORWARD.json REVERSE.json OUTPUT.json\n"
                         f"       {sys.argv[0]} --self-check")

    result = combine(load(argv[0]), load(argv[1]))
    with open(argv[2], "w", encoding="utf-8", newline="\n") as stream:
        json.dump(result, stream, ensure_ascii=False, indent=2)
        stream.write("\n")


if __name__ == "__main__":
    main(sys.argv[1:])
