"""Draws the report's SVG figures from results/*.json.

Usage: python figures.py <results_dir> <out_dir>
Requires matplotlib (tested with 3.10.7).
"""
import json
import sys
from pathlib import Path

import plistlib
import subprocess

# Work-around: on macOS 27, `system_profiler SPFontsDataType` output lacks
# the "_items" key that matplotlib's font scan expects, so the call is
# answered with an empty list (fonts are still found by directory scan).
_check_output = subprocess.check_output


def _patched(cmd, *a, **k):
    if isinstance(cmd, (list, tuple)) and cmd and cmd[0] == "system_profiler":
        return plistlib.dumps([{"_items": []}])
    return _check_output(cmd, *a, **k)


subprocess.check_output = _patched

import matplotlib  # noqa: E402

matplotlib.use("svg")
import matplotlib.pyplot as plt  # noqa: E402

res = Path(sys.argv[1])
out = Path(sys.argv[2])
out.mkdir(parents=True, exist_ok=True)

INK = "#1d2330"
MUTED = "#5b6475"
GRID = "#e3e6ec"
BLUE = "#2f5bd3"
ORANGE = "#d9692b"
GREEN = "#1f8a5b"
GREY = "#8a93a5"
PURPLE = "#7a4bc2"

plt.rcParams.update({
    "svg.fonttype": "none",
    "font.family": ["Helvetica", "Arial", "DejaVu Sans", "sans-serif"],
    "font.size": 13,
    "axes.titlesize": 14,
    "axes.labelsize": 13,
    "xtick.labelsize": 12.5,
    "ytick.labelsize": 12.5,
    "legend.fontsize": 12.5,
    "axes.edgecolor": MUTED,
    "axes.labelcolor": INK,
    "xtick.color": MUTED,
    "ytick.color": MUTED,
    "text.color": INK,
    "axes.grid": True,
    "grid.color": GRID,
    "grid.linewidth": 0.8,
    "axes.spines.top": False,
    "axes.spines.right": False,
    "figure.facecolor": "white",
    "axes.facecolor": "white",
    "savefig.facecolor": "white",
})


def save(fig, name, top=1.0):
    fig.tight_layout(rect=(0, 0, 1, top))
    fig.savefig(out / name, format="svg", metadata={"Date": None})
    plt.close(fig)


from matplotlib.ticker import FixedLocator, FuncFormatter, NullLocator  # noqa: E402


def plain_log_y(ax, ticks):
    ax.yaxis.set_major_locator(FixedLocator(ticks))
    ax.yaxis.set_minor_locator(NullLocator())
    ax.yaxis.set_major_formatter(FuncFormatter(lambda v, _: f"{v:,.0f}" if v >= 1 else f"{v:g}"))


def ci(s):
    return s["mean"], s["mean"] - s["ci95"][0], s["ci95"][1] - s["mean"]


e1 = json.loads((res / "e1_summary.json").read_text())
ms = [r["m"] for r in e1["byExpenseCount"]]

# Figure 1 (hero): money created or destroyed per ledger.
fig, ax = plt.subplots(figsize=(7.0, 4.2))
y, lo, hi = zip(*[ci(r["round_money_created_minor"]) for r in e1["byExpenseCount"]])
ax.errorbar(ms, y, yerr=[lo, hi], color=ORANGE, marker="o", lw=2.2, capsize=4,
            label="Doubles, each share rounded to the minor unit")
for m, v in zip(ms, y):
    ax.annotate(f"{v:,.0f}", (m, v), textcoords="offset points", xytext=(-8, 9),
                ha="right", color=ORANGE, fontsize=12.5)
ax.plot(ms, [0.5] * len(ms), color=BLUE, marker="s", lw=2.2,
        label="Integer package: exactly 0 in every ledger")
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_ylim(0.3, 4000)
ax.yaxis.set_minor_locator(NullLocator())
ax.set_yticks([0.5, 1, 10, 100, 1000])
ax.set_yticklabels(["0", "1", "10", "100", "1,000"])
ax.set_xticks(ms)
ax.xaxis.set_minor_locator(NullLocator())
ax.set_xticklabels([f"{m:,}" for m in ms])
ax.set_xlabel("Expenses in the ledger")
ax.set_ylabel("Minor units created or lost\n(mean per ledger, 95% CI)")
ax.set_title("Rounding each share separately leaks money; integers do not", loc="left")
ax.legend(loc="upper left", frameon=False)
save(fig, "fig1-money-leak.svg")

# Figure 2: long-run allocation bias of the remainder rule.
fig, ax = plt.subplots(figsize=(7.0, 4.2))
series = [
    ("int_max_dev_from_exact_minor", "Package rule (heaviest weight, then id)", BLUE, "s"),
    ("hamilton_max_dev_from_exact_minor", "Largest remainder, ties by id", GREY, "^"),
    ("rotated_max_dev_from_exact_minor", "Largest remainder, ties rotated per expense", GREEN, "o"),
]
for key, label, col, mk in series:
    y, lo, hi = zip(*[ci(r[key]) for r in e1["byExpenseCount"]])
    ax.errorbar(ms, y, yerr=[lo, hi], color=col, marker=mk, lw=2, capsize=3, label=label)
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xticks(ms)
ax.xaxis.set_minor_locator(NullLocator())
ax.set_xticklabels([f"{m:,}" for m in ms])
ax.set_xlabel("Expenses in the ledger")
ax.set_ylabel("Largest gap from the exact\nrational balance (minor units)")
plain_log_y(ax, [1, 10, 100, 1000])
ax.set_title("Exact totals, but a fixed tie-break drifts against one person", loc="left")
ax.legend(loc="upper left", frameon=False)
save(fig, "fig2-tiebreak-bias.svg")

# Figure 3: simplifier optimality gap.
e2 = json.loads((res / "e2_summary.json").read_text())
fig, axes = plt.subplots(1, 3, figsize=(9.6, 3.9), sharey=True)
names = {"uniform": "Uneven multi-payer bills", "round": "Round equal splits",
         "pairs": "One-to-one IOUs"}
cols = [BLUE, ORANGE, PURPLE, INK]
for ax, g in zip(axes, ["uniform", "round", "pairs"]):
    rows = [r for r in e2["byGenAndSize"] if r["gen"] == g and r["n"] <= 10]
    ns = [r["n"] for r in rows]
    bottom = [0.0] * len(rows)
    for gap, col in zip(["0", "1", "2", "3"], cols):
        vals = [r["greedyGapHistogram"].get(gap, 0) / r["instances"] for r in rows]
        ax.bar(ns, vals, bottom=bottom, color=col, width=0.75,
               label={"0": "optimal", "1": "+1 transfer", "2": "+2", "3": "+3"}[gap])
        bottom = [b + v for b, v in zip(bottom, vals)]
    ax.set_title(names[g], loc="left", fontsize=13)
    ax.set_xticks(ns)
    ax.set_xlabel("People in the group")
    ax.grid(axis="x", visible=False)
axes[0].set_ylabel("Share of 2,000 ledgers")
axes[0].set_ylim(0, 1)
h, l = axes[0].get_legend_handles_labels()
fig.legend(h, l, loc="upper center", ncol=4, frameon=False, bbox_to_anchor=(0.5, 1.0))
save(fig, "fig3-simplifier-gap.svg", top=0.9)

# Figure 4: routed payments.
e3 = json.loads((res / "e3_summary.json").read_text())
fig, axes = plt.subplots(1, 2, figsize=(9.6, 3.9), sharey=True)
strat = [("naiveClamp", "Plain payment (clamped pair)", ORANGE, "o"),
         ("bridge", "Routed bridge", BLUE, "s"),
         ("guarded", "Bridge + fall back to direct", GREEN, "^")]
for ax, g in zip(axes, ["uniform", "pairs"]):
    rows = [r for r in e3["byGenAndSize"] if r["gen"] == g]
    ns = [r["n"] for r in rows]
    for key, label, col, mk in strat:
        y = [r[key]["unsettledFraction"] * 100 for r in rows]
        lo = [max(0.0, (r[key]["unsettledFraction"] - r[key]["unsettledWilson95"][0]) * 100) for r in rows]
        hi = [max(0.0, (r[key]["unsettledWilson95"][1] - r[key]["unsettledFraction"]) * 100) for r in rows]
        ax.errorbar(ns, y, yerr=[lo, hi], color=col, marker=mk, lw=2, capsize=3, label=label)
    ax.set_title(names[g], loc="left", fontsize=13)
    ax.set_xticks(ns)
    ax.set_xlabel("People in the group")
    ax.set_ylim(-3, 103)
axes[0].set_ylabel("Ledgers left unsettled after\npaying every simplified row (%)")
h, l = axes[0].get_legend_handles_labels()
fig.legend(h, l, loc="upper center", ncol=3, frameon=False, bbox_to_anchor=(0.5, 1.0))
save(fig, "fig4-routed-payments.svg", top=0.9)

# Figure 5: runtime.
e5 = json.loads((res / "e5_runtime.json").read_text())
fig, ax = plt.subplots(figsize=(7.0, 4.2))
ops = [("netBalances", "Net balances", BLUE, "s"),
       ("settlementsDirect", "Direct plan", GREEN, "o"),
       ("settlementsSimplified", "Simplified plan", ORANGE, "^"),
       ("decomposeWholePlan", "Decompose every simplified row", PURPLE, "D")]
for op, label, col, mk in ops:
    rows = [r for r in e5["rows"] if r["op"] == op and r["n"] == 10]
    ax.plot([r["m"] for r in rows], [r["cpu_min_us"] for r in rows],
            color=col, marker=mk, lw=2, label=label)
ax.set_xscale("log")
ax.set_yscale("log")
ax.set_xticks([10, 100, 1000])
ax.xaxis.set_minor_locator(NullLocator())
ax.set_xticklabels(["10", "100", "1,000"])
ax.set_xlabel("Expenses (group of 10 people)")
ax.set_ylabel("CPU time per call, best of 21 (µs)")
plain_log_y(ax, [1, 10, 100, 1000, 10000])
ax.set_title("Cost grows linearly with expenses", loc="left")
ax.legend(loc="upper left", frameon=False)
save(fig, "fig5-runtime.svg")
print("figures written to", out)
