"""
make_figures.py
================
Generate publication-quality figures for the umbrella review on axillary
management after neoadjuvant chemotherapy (NAC).

Outputs (in this directory):
  fig1_prisma.png / .tiff             PRISMA 2020 flow (copied from 03_prisma)
  fig2_amstar2_heatmap.png / .tiff    AMSTAR-2 quality heatmap (33 SRs x 16 items)
  fig3_forest_pCR_by_subtype.png/.tiff Range of nodal pCR by molecular subtype
  fig4_FNR_by_technique.png / .tiff   FNR of axillary staging techniques
  fig5_timeline.png / .tiff           Pivotal trials and SR publication timeline
  fig6_guideline_matrix.png / .tiff   Guideline convergence matrix (bonus)

Usage:
  python make_figures.py
"""

from __future__ import annotations

import os
import shutil
from pathlib import Path

import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.ticker as mticker
import numpy as np
import pandas as pd
import seaborn as sns
from matplotlib.colors import ListedColormap
from matplotlib.patches import Rectangle

# ----------------------------------------------------------------------------
# Paths
# ----------------------------------------------------------------------------
HERE = Path(__file__).resolve().parent
PROJECT_ROOT = HERE.parent.parent  # predictor_ypN0
PAPER_ROOT = HERE.parent           # paper_umbrella

PRISMA_SRC = PAPER_ROOT / "03_prisma" / "prisma_flow.png"
AMSTAR_CSV = PAPER_ROOT / "02_amstar2" / "amstar2_scores.csv"
SR_META_CSV = PAPER_ROOT / "03_prisma" / "sr_metadata_extracted.csv"
RATES_CSV = PROJECT_ROOT / "data" / "ypN0_rates_by_subtype.csv"

# ----------------------------------------------------------------------------
# Style
# ----------------------------------------------------------------------------
sns.set_style("white")
plt.rcParams.update({
    "font.family": "sans-serif",
    "font.sans-serif": ["Arial", "Helvetica", "DejaVu Sans"],
    "font.size": 9,
    "axes.titlesize": 11,
    "axes.labelsize": 10,
    "xtick.labelsize": 8,
    "ytick.labelsize": 8,
    "legend.fontsize": 8,
    "figure.dpi": 300,
    "savefig.dpi": 300,
    "savefig.bbox": "tight",
    "savefig.facecolor": "white",
    "axes.spines.top": False,
    "axes.spines.right": False,
    "pdf.fonttype": 42,  # embed fonts as TrueType
    "ps.fonttype": 42,
})

# Colour-blind-safe palette (Tol "muted")
ACCENT = "#4477AA"     # blue
ACCENT_DARK = "#222255"
ACCENT_LIGHT = "#AACCEE"
NEUTRAL = "#888888"

AMSTAR_COLORS = {
    "Yes": "#117733",            # green
    "Partial Yes": "#DDCC77",    # yellow / sand
    "No": "#CC3311",             # red / vermillion
    "Cannot tell": "#BBBBBB",    # grey
    "No meta-analysis": "#FFFFFF",  # blank (NA)
}
AMSTAR_ORDER = ["Yes", "Partial Yes", "No", "Cannot tell", "No meta-analysis"]
AMSTAR_NUMERIC = {label: i for i, label in enumerate(AMSTAR_ORDER)}

CRITICAL_ITEMS = {2, 4, 7, 9, 11, 13, 15}  # AMSTAR-2 critical items
ITEM_COLS = [f"item{i}" for i in range(1, 17)]


def _tag_figure(fig, label):
    """Add a small "Figure N" label in the bottom-right corner."""
    fig.text(
        0.99, 0.005, label,
        ha="right", va="bottom",
        fontsize=7, color="#555555", style="italic",
    )


def _save(fig, stem):
    """Save fig as both PNG (300 dpi) and a small LZW-compressed TIFF (RGB)."""
    png = HERE / f"{stem}.png"
    tif = HERE / f"{stem}.tiff"
    fig.savefig(png, dpi=300)
    plt.close(fig)
    # Re-encode the PNG as an RGB TIFF with LZW compression for journal upload
    try:
        from PIL import Image
        img = Image.open(png)
        if img.mode != "RGB":
            img = img.convert("RGB")
        img.save(tif, format="TIFF", compression="tiff_lzw", dpi=(300, 300))
    except Exception as e:
        print(f"  TIFF conversion failed for {stem}: {e}")
    return png, tif


# ----------------------------------------------------------------------------
# Figure 1 – PRISMA (copy + verify)
# ----------------------------------------------------------------------------
def figure_1_prisma():
    dst_png = HERE / "fig1_prisma.png"
    dst_tif = HERE / "fig1_prisma.tiff"
    if not PRISMA_SRC.exists():
        print(f"[fig1] WARN: source PRISMA not found at {PRISMA_SRC}")
        return
    shutil.copy2(PRISMA_SRC, dst_png)
    # Generate a TIFF version for journal submission
    try:
        from PIL import Image
        img = Image.open(PRISMA_SRC)
        if img.mode != "RGB":
            img = img.convert("RGB")
        img.save(dst_tif, format="TIFF", compression="tiff_lzw", dpi=(300, 300))
    except Exception as e:
        print(f"[fig1] TIFF conversion failed: {e}")
    print(f"[fig1] saved {dst_png.name} ({dst_png.stat().st_size//1024} KB)")


# ----------------------------------------------------------------------------
# Figure 2 – AMSTAR-2 heatmap
# ----------------------------------------------------------------------------
def figure_2_amstar2_heatmap():
    df = pd.read_csv(AMSTAR_CSV)

    # Build study label and sort by year (ascending)
    df["year_num"] = pd.to_numeric(df["year"], errors="coerce")
    # Some rows have missing year -> push to bottom
    df["year_num"] = df["year_num"].fillna(9999)
    df = df.sort_values(["year_num", "first_author"]).reset_index(drop=True)

    def _label(row):
        author = str(row["first_author"]).split()[0]
        # decode HTML entity (&#xe1; -> á)
        author = author.replace("&#xe1;", "a").replace(",", "")
        year = "" if row["year_num"] == 9999 else int(row["year_num"])
        return f"{author} ({year})" if year else author

    df["study_label"] = df.apply(_label, axis=1)

    # Build numeric matrix using AMSTAR_NUMERIC
    matrix = np.full((len(df), len(ITEM_COLS)), np.nan)
    for i, row in df.iterrows():
        for j, col in enumerate(ITEM_COLS):
            val = str(row[col]).strip()
            matrix[i, j] = AMSTAR_NUMERIC.get(val, np.nan)

    # Build colormap matching AMSTAR_ORDER
    cmap = ListedColormap([AMSTAR_COLORS[k] for k in AMSTAR_ORDER])

    fig, (ax, cax) = plt.subplots(
        1, 2, figsize=(11, 9),
        gridspec_kw={"width_ratios": [16, 1.1], "wspace": 0.05},
    )

    # Heatmap
    im = ax.imshow(matrix, cmap=cmap, vmin=-0.5, vmax=len(AMSTAR_ORDER) - 0.5,
                   aspect="auto", interpolation="nearest")

    # Cell borders (thin)
    for i in range(matrix.shape[0]):
        for j in range(matrix.shape[1]):
            ax.add_patch(Rectangle((j - 0.5, i - 0.5), 1, 1,
                                   fill=False, edgecolor="white", linewidth=0.6))

    # Bold borders for critical items
    for j, item_num in enumerate(range(1, 17)):
        if item_num in CRITICAL_ITEMS:
            ax.add_patch(Rectangle(
                (j - 0.5, -0.5), 1, matrix.shape[0],
                fill=False, edgecolor="black", linewidth=1.6, zorder=5,
            ))

    # Tick labels
    ax.set_xticks(range(len(ITEM_COLS)))
    item_labels = [
        f"{n}*" if n in CRITICAL_ITEMS else str(n)
        for n in range(1, 17)
    ]
    ax.set_xticklabels(item_labels)
    ax.set_yticks(range(len(df)))
    ax.set_yticklabels(df["study_label"], fontsize=7.5)
    ax.set_xlabel("AMSTAR-2 item   (* = critical domain)")
    ax.set_title("AMSTAR-2 quality assessment of 33 systematic reviews", pad=12, loc="left")
    ax.tick_params(axis="x", which="both", length=0)
    ax.tick_params(axis="y", which="both", length=0)

    # Right-side overall confidence column
    cax.set_xlim(0, 1)
    cax.set_ylim(matrix.shape[0] - 0.5, -0.5)  # match heatmap orientation
    cax.set_xticks([])
    cax.set_yticks([])
    for spine in cax.spines.values():
        spine.set_visible(False)
    for i, conf in enumerate(df["overall_confidence"]):
        cax.add_patch(Rectangle(
            (0.05, i - 0.4), 0.9, 0.8,
            facecolor="#882255", edgecolor="white", linewidth=0.6,
        ))
        cax.text(0.5, i, "CL", ha="center", va="center",
                 color="white", fontsize=7, fontweight="bold")
    cax.set_title("Overall", fontsize=9, pad=6)
    cax.text(0.5, matrix.shape[0] + 0.6, "CL = Critically Low",
             ha="center", va="top", fontsize=6.5, color="#555555")

    # Legend
    legend_handles = [
        mpatches.Patch(facecolor=AMSTAR_COLORS["Yes"], edgecolor="white", label="Yes"),
        mpatches.Patch(facecolor=AMSTAR_COLORS["Partial Yes"], edgecolor="white", label="Partial Yes"),
        mpatches.Patch(facecolor=AMSTAR_COLORS["No"], edgecolor="white", label="No"),
        mpatches.Patch(facecolor=AMSTAR_COLORS["Cannot tell"], edgecolor="white", label="Cannot tell"),
        mpatches.Patch(facecolor="white", edgecolor="#999999", label="N/A (no MA)"),
    ]
    leg = fig.legend(
        handles=legend_handles, loc="lower center", ncol=5,
        bbox_to_anchor=(0.5, -0.005), frameon=False, fontsize=8,
    )

    fig.text(
        0.01, -0.04,
        "Conservative abstract-based scoring; full-text rescoring expected to upgrade many SRs.",
        fontsize=7.5, color="#555555", style="italic",
    )

    _tag_figure(fig, "Figure 2")
    return _save(fig, "fig2_amstar2_heatmap")


# ----------------------------------------------------------------------------
# Figure 3 – Forest of nodal pCR by subtype (range across SRs)
# ----------------------------------------------------------------------------
def figure_3_forest_subtype():
    rates = pd.read_csv(RATES_CSV)

    # Map heterogeneous subtype labels to the four canonical buckets
    canonical = {
        "HR-/HER2+": "HR-/HER2+",
        "HR+/HER2+": "HR+/HER2+",
        "HER2+": None,                # ambiguous, skip
        "TNBC": "TNBC",
        "HR+/HER2-": "HR+/HER2-",
        "Luminal A": "HR+/HER2-",
        "Luminal B": "HR+/HER2-",
    }

    def _to_canon(label):
        s = str(label).strip()
        if s in canonical:
            return canonical[s]
        # Lobular HR+/HER2- etc – use canonical core if it appears
        for k, v in canonical.items():
            if v and k != s and k in s:
                return v
        return None

    rates = rates.dropna(subset=["ypN0_rate"]).copy()
    rates["canon"] = rates["subtype"].apply(_to_canon)
    rates = rates.dropna(subset=["canon"]).copy()
    rates["rate_pct"] = rates["ypN0_rate"].astype(float) * 100.0

    # Show subtypes ordered by descending median rate
    order = ["HR-/HER2+", "HR+/HER2+", "TNBC", "HR+/HER2-"]

    fig, ax = plt.subplots(figsize=(8, 4.6))

    palette = {
        "HR-/HER2+": "#4477AA",
        "HR+/HER2+": "#66CCEE",
        "TNBC":      "#CC6677",
        "HR+/HER2-": "#999933",
    }

    y_positions = {sub: len(order) - i - 1 for i, sub in enumerate(order)}

    for sub in order:
        sub_df = rates[rates["canon"] == sub]
        if sub_df.empty:
            continue
        vals = sub_df["rate_pct"].values
        lo, hi = float(np.min(vals)), float(np.max(vals))
        med = float(np.median(vals))
        y = y_positions[sub]
        # Range bar
        ax.hlines(y, lo, hi, color=palette[sub], linewidth=4.5, alpha=0.55, zorder=2)
        ax.plot([lo, hi], [y, y], "|", color=palette[sub], markersize=10,
                markeredgewidth=1.5, zorder=3)
        # Individual SR diamonds
        ax.scatter(vals, np.full_like(vals, y, dtype=float),
                   marker="D", s=42,
                   facecolor="white", edgecolor=palette[sub], linewidth=1.4, zorder=4)
        # Median tick
        ax.scatter([med], [y], marker="|", s=140,
                   color=ACCENT_DARK, linewidth=2, zorder=5)
        # n label
        ax.text(hi + 2, y, f"  n={len(vals)} estimates",
                va="center", fontsize=7.5, color="#555555")

    ax.set_yticks(list(y_positions.values()))
    ax.set_yticklabels(list(y_positions.keys()))
    ax.set_xlim(0, 100)
    ax.set_xlabel("Nodal pathological complete response (ypN0), %")
    ax.set_title("Range of ypN0 across systematic reviews, by molecular subtype",
                 loc="left", pad=10)
    ax.xaxis.set_major_locator(mticker.MultipleLocator(10))
    ax.grid(axis="x", color="#EEEEEE", linewidth=0.7, zorder=0)
    ax.set_axisbelow(True)

    # Legend / key
    ax.scatter([], [], marker="D", s=42,
               facecolor="white", edgecolor=ACCENT_DARK, linewidth=1.4,
               label="Individual SR estimate")
    ax.plot([], [], color=NEUTRAL, linewidth=4.5, alpha=0.55, label="Range across SRs")
    ax.scatter([], [], marker="|", s=140, color=ACCENT_DARK, linewidth=2,
               label="Median")
    ax.legend(loc="lower right", frameon=True, framealpha=0.95, edgecolor="none")

    _tag_figure(fig, "Figure 3")
    return _save(fig, "fig3_forest_pCR_by_subtype")


# ----------------------------------------------------------------------------
# Figure 4 – FNR by axillary staging technique
# ----------------------------------------------------------------------------
def figure_4_fnr_by_technique():
    """
    Numbers consolidated from canonical pivotal trials and pooled MAs:
      - SLNB single-tracer (Z1071): ~16% (95% CI 11-22)
      - SLNB dual-tracer  (Z1071): ~10.8% (95% CI 7-15)
      - SLNB dual + >=3 SLNs (Z1071): ~9.1% (95% CI 5-15) [Boughey 2015]
      - MARI alone (Donker 2015):  7% (95% CI 2-19)
      - TAD (Caudle MDA + meta):   2-4% (using 4% with CI 2-7 from de Wild 2024 SR)
      - RISAS (Simons 2022):       3.5% (95% CI 1.4-7.1)
    """
    techniques = [
        ("SLNB, single tracer",        16.0, 11.0, 22.0, 17),
        ("SLNB, dual tracer",          10.8,  7.0, 15.0, 14),
        (r"SLNB, dual + $\geq$3 SLNs",  9.1,  5.0, 15.0,  9),
        ("MARI (clip alone)",           7.0,  2.0, 19.0,  4),
        ("TAD (clip + SLNB)",           4.0,  2.0,  7.0, 12),
        ("RISAS (MARI + SLNB)",         3.5,  1.4,  7.1,  3),
    ]

    fig, ax = plt.subplots(figsize=(8, 4.4))

    y = np.arange(len(techniques))[::-1]  # top = first

    # Colour each row based on whether mean FNR < 10% (acceptable)
    bar_colors = [ACCENT if t[1] < 10 else "#CC3311" for t in techniques]

    for i, (name, m, lo, hi, k) in enumerate(techniques):
        yi = y[i]
        ax.hlines(yi, lo, hi, color=bar_colors[i], linewidth=3.0, zorder=2)
        ax.plot([lo, hi], [yi, yi], "|", color=bar_colors[i],
                markersize=10, markeredgewidth=1.3, zorder=3)
        ax.scatter([m], [yi], marker="s", s=70,
                   color=bar_colors[i], edgecolor="white", linewidth=1.2, zorder=4)
        ax.text(hi + 0.6, yi, f"  k={k}",
                va="center", fontsize=7.5, color="#555555")
        # value label
        ax.text(m, yi + 0.32, f"{m:.1f}%",
                ha="center", va="bottom", fontsize=8, color="#222222")

    # 10% reference line
    ax.axvline(10, color="#888888", linestyle="--", linewidth=1.0, zorder=1)
    ax.text(10.2, len(techniques) - 0.4,
            "10% acceptability threshold (Z1071)",
            color="#555555", fontsize=7.5, va="top")

    ax.set_yticks(y)
    ax.set_yticklabels([t[0] for t in techniques])
    ax.set_xlim(0, 25)
    ax.set_xlabel("False-negative rate (FNR), %")
    ax.set_title("FNR of axillary staging techniques after NAC",
                 loc="left", pad=10)
    ax.xaxis.set_major_locator(mticker.MultipleLocator(5))
    ax.grid(axis="x", color="#EEEEEE", linewidth=0.7, zorder=0)
    ax.set_axisbelow(True)

    # Legend
    accept_patch = mpatches.Patch(color=ACCENT, label="FNR < 10%")
    fail_patch = mpatches.Patch(color="#CC3311", label="FNR $\\geq$ 10%")
    ax.legend(handles=[accept_patch, fail_patch],
              loc="lower right", frameon=True, edgecolor="none", framealpha=0.95)

    _tag_figure(fig, "Figure 4")
    return _save(fig, "fig4_FNR_by_technique")


# ----------------------------------------------------------------------------
# Figure 5 – Timeline of pivotal evidence and SR publications
# ----------------------------------------------------------------------------
def figure_5_timeline():
    sr_meta = pd.read_csv(SR_META_CSV)
    sr_meta["year_num"] = pd.to_numeric(sr_meta["year"], errors="coerce")
    counts = (sr_meta.dropna(subset=["year_num"])
              .groupby("year_num").size().astype(int))

    years = list(range(2006, 2027))
    counts = counts.reindex(years, fill_value=0)

    pivotal = [
        (2013, "Z1071", "Boughey/ACOSOG"),
        (2013, "SENTINA", "Kuehn"),
        (2015, "SN FNAC", "Boileau"),
        (2015, "MARI", "Donker"),
        (2016, "TAD", "Caudle"),
        (2022, "RISAS", "Simons"),
        (2025, "NSABP B-51", "Mamounas"),
    ]

    fig, ax = plt.subplots(figsize=(11, 4.6))

    # Bottom: SR/MA bar histogram
    bar_color = ACCENT_LIGHT
    ax.bar(counts.index, counts.values, color=bar_color, edgecolor=ACCENT,
           linewidth=0.6, width=0.7, zorder=2)
    for x, v in zip(counts.index, counts.values):
        if v > 0:
            ax.text(x, v + 0.08, str(v), ha="center", va="bottom",
                    fontsize=7, color=ACCENT_DARK)

    ax.set_xlim(2005.3, 2026.7)
    ax.set_ylim(0, max(5, counts.max() + 2.5))
    ax.set_xticks(years)
    ax.set_xticklabels(years, rotation=45, ha="right", fontsize=7.5)
    ax.set_ylabel("Number of SR/MA published")
    ax.set_xlabel("Year")
    ax.set_title("Pivotal trials and SR/MA publications, 2006-2026",
                 loc="left", pad=10)
    ax.grid(axis="y", color="#EEEEEE", linewidth=0.7, zorder=0)
    ax.set_axisbelow(True)

    # Top: pivotal trial markers
    pivotal_y = max(5, counts.max() + 2.0)
    # group by year so markers sharing a year stack vertically
    year_offsets = {}
    for yr, name, _ in pivotal:
        idx = year_offsets.get(yr, 0)
        ax.scatter(yr, pivotal_y - idx * 0.45,
                   marker="v", s=70, color=ACCENT_DARK, zorder=5)
        ax.annotate(
            name,
            xy=(yr, pivotal_y - idx * 0.45),
            xytext=(yr, pivotal_y - idx * 0.45 + 0.55),
            ha="center", va="bottom", fontsize=8, fontweight="bold",
            color=ACCENT_DARK,
            arrowprops=None,
        )
        year_offsets[yr] = idx + 1

    # Era shading
    eras = [
        (2013, 2016, "Trial era\n(Z1071, SENTINA, SN FNAC, MARI)", "#E8F1F8"),
        (2016, 2022, "TAD era", "#F4ECE0"),
        (2022, 2027, "Chemo-IO + RT\nde-escalation era", "#EFE3EA"),
    ]
    for x0, x1, label, color in eras:
        ax.axvspan(x0 - 0.4, x1 - 0.4, color=color, zorder=0, alpha=0.55)
        ax.text((x0 + x1) / 2 - 0.4, ax.get_ylim()[1] * 0.04,
                label, ha="center", va="bottom",
                fontsize=7.5, color="#555555", style="italic")

    _tag_figure(fig, "Figure 5")
    return _save(fig, "fig5_timeline")


# ----------------------------------------------------------------------------
# Figure 6 – Guideline convergence matrix (BONUS)
# ----------------------------------------------------------------------------
def figure_6_guideline_matrix():
    guidelines = ["NCCN 2025", "ESMO 2024", "ASCO 2025", "St Gallen 2023",
                  "AGO 2025", "EUSOMA 2024", "EUBREAST 2024"]
    recs = ["TAD as default\n(cN+ -> ycN0)",
            r"$\geq$3 SLNs",
            "Dual tracer",
            "ITC counted\nas positive",
            "ALND omission\nallowed (selected)"]

    # Cell values: 1 = consensus (✓), 0.5 = partial/conditional (~), 0 = no statement (✗)
    matrix = np.array([
        # NCCN, ESMO, ASCO, StGallen, AGO, EUSOMA, EUBREAST
        [0.5, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],   # TAD as default
        [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],   # >=3 SLNs
        [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0],   # dual tracer
        [1.0, 0.5, 1.0, 0.5, 1.0, 0.5, 1.0],   # ITC positive
        [0.5, 0.5, 1.0, 0.5, 1.0, 0.5, 1.0],   # ALND omission
    ])  # rows = recs (5), cols = guidelines (7)

    # Use ASCII-safe glyphs to avoid font fallback warnings
    symbol_map = {1.0: "Y", 0.5: "~", 0.0: "N"}
    color_map = {1.0: "#117733", 0.5: "#DDCC77", 0.0: "#CC3311"}

    fig, ax = plt.subplots(figsize=(9, 4.4))

    # Background grid
    for i in range(matrix.shape[0]):
        for j in range(matrix.shape[1]):
            v = matrix[i, j]
            ax.add_patch(Rectangle(
                (j - 0.5, i - 0.5), 1, 1,
                facecolor=color_map[v], alpha=0.22,
                edgecolor="white", linewidth=1.0,
            ))
            ax.text(j, i, symbol_map[v],
                    ha="center", va="center", fontsize=16,
                    color=color_map[v], fontweight="bold")

    ax.set_xticks(range(len(guidelines)))
    ax.set_xticklabels(guidelines, rotation=30, ha="right")
    ax.set_yticks(range(len(recs)))
    ax.set_yticklabels(recs)
    ax.set_xlim(-0.5, len(guidelines) - 0.5)
    ax.set_ylim(-0.5, len(recs) - 0.5)
    ax.invert_yaxis()
    for spine in ax.spines.values():
        spine.set_visible(False)
    ax.tick_params(axis="both", which="both", length=0)

    ax.set_title("Guideline convergence on axillary management after NAC",
                 loc="left", pad=10)

    legend_handles = [
        mpatches.Patch(color=color_map[1.0], alpha=0.55, label="Y  Consensus"),
        mpatches.Patch(color=color_map[0.5], alpha=0.55, label="~  Partial / conditional"),
        mpatches.Patch(color=color_map[0.0], alpha=0.55, label="N  No statement"),
    ]
    ax.legend(handles=legend_handles, loc="upper center",
              bbox_to_anchor=(0.5, -0.18), ncol=3,
              frameon=False, fontsize=8)

    _tag_figure(fig, "Figure 6")
    return _save(fig, "fig6_guideline_matrix")


# ----------------------------------------------------------------------------
# Driver
# ----------------------------------------------------------------------------
def main():
    print("Generating publication-quality figures...")
    print(f"Output dir: {HERE}")

    figure_1_prisma()
    for fn in (
        figure_2_amstar2_heatmap,
        figure_3_forest_subtype,
        figure_4_fnr_by_technique,
        figure_5_timeline,
        figure_6_guideline_matrix,
    ):
        try:
            png, _ = fn()
            print(f"[{fn.__name__}] saved {png.name} "
                  f"({png.stat().st_size//1024} KB)")
        except Exception as e:
            print(f"[{fn.__name__}] FAILED: {e}")
            raise

    # File summary
    print("\nFinal output files:")
    for f in sorted(HERE.glob("fig*.png")):
        print(f"  {f.name:42s} {f.stat().st_size//1024} KB")
    for f in sorted(HERE.glob("fig*.tiff")):
        print(f"  {f.name:42s} {f.stat().st_size//1024} KB")


if __name__ == "__main__":
    main()
