#!/usr/bin/env python3
"""Center/North/CCD min30 vs min20 grouping-sensitivity slider producer.

Creates a new grouping-sensitivity comparison without modifying any
predecessor. The min30 panel reuses the exact Center/North/Control arrays
from the validated regional manifest. The min20 panel regroups the same
seeded PHAs with group_counts(20) and folds the same Center source through
the same eight epoch-specific Chandra RMFs before exposure weighting, then
conservatively rebins to the new min30 display grid.

Both panels share identical:
  - 25 AtomDB/SQUDE grouped keV reference labels in a separate top band;
  - colors, legend roles, energy range, y-axis semantics;
  - line-label producer and bounded-evidence pins.

They differ only in grouping threshold, so the slider is a pure display
sensitivity comparison.
"""
from __future__ import annotations

import argparse
import copy
import hashlib
import json
import os
import shutil
import sys
import uuid
from importlib.metadata import version as package_version
from pathlib import Path
from typing import Any

import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import scienceplots  # noqa: F401
from sherpa.astro.ui import (
    clean,
    get_data,
    get_data_plot,
    group_counts,
    load_arf,
    load_pha,
    load_rmf,
    notice_id,
    set_analysis,
    ungroup,
)

ROOT = Path(__file__).resolve().parents[1]
SOURCE_MANIFEST = (
    ROOT
    / "data/fits/m82_three_region_mock_tutorial/"
    "m82_three_region_joint_center_manifest.json"
)
SOURCE_MANIFEST_SHA256 = "1d6cbe29c1c0a47c6ced5a1fd9e65cd6d3e5d85207eece65a30777b9e8d7868b"
RGS_LABEL_SOURCE_SHA256 = "1fdfe12927c44ddca08e30f0425fead8c965c707960a3b33c5a2cbbe1007edca"
ATOMDB_LINE_LIST_SHA256 = "e5d8a76f571c8cea6facb3c7dee6d225291583f087c56231a72dd3695a9a88ee"
SQUDE_LINE_MARKER_SOURCE_SHA256 = "1d2250f3b774051d7fa2823f68d0595514e5fa997e7e92c3f8b4ba4239915e25"
LINE_EVIDENCE = ROOT / "data/fits/m82_line_label_v2_atomdb_evidence.json"
LINE_EVIDENCE_SHA256 = "3003f6831c4db4e4a7cbb54cb2a03c28c1e9a110d704aaa9c644a1c9aaa5cd20"
DEFAULT_OUTPUT = ROOT / "data/fits/m82_center_north_min20_vs_min30_slider"
PREFIX = "m82_center_north_min20_vs_min30_slider"
ENERGY_RANGE_KEV = (0.5, 1.7)
CENTER_PHA = ROOT / "data/fits/m82_three_region_mock_tutorial/v40_center_squde_50ks.pha"
NORTH_PHA = ROOT / "data/fits/m82_three_region_mock_tutorial/v80_north_squde_50ks.pha"
SQUDE_ARF = ROOT / "SQUDE_response/SQUDE_rsp_v1.1.1.arf"
SQUDE_RMF = ROOT / "SQUDE_response/SQUDE_rsp_v1.1.2.rmf"
CENTER_PHA_SHA256 = "0816e65ccd469f15b3ca879ec99f7a773ef6edff0153fbc2f332d5205d84712a"
NORTH_PHA_SHA256 = "894438bb7afc960521c5f43a072c0f1f1c21e2312ecfe294ff4c009ede4178b1"
SQUDE_ARF_SHA256 = "b8fc423643944fa501082875784f410c82f7b070e3f00f630642354e4ea7b146"
SQUDE_RMF_SHA256 = "4928badfcfb3a4807fa2dabdb932fbf8eba0123ffb66708b7651bf4578ac8cbf"

LINE_LABELS = [
    {"energy_keV": 0.568, "label": "O VII triplet", "row": 0},
    {"energy_keV": 0.654, "label": "O VIII Lyα", "row": 1},
    {"energy_keV": 0.704, "label": "Fe XVIII", "row": 2},
    {"energy_keV": 0.727, "label": "Fe XVII blend", "row": 0},
    {"energy_keV": 0.774, "label": "Fe XVIII / O VIII", "row": 1},
    {"energy_keV": 0.794, "label": "Fe XVIII", "row": 4},
    {"energy_keV": 0.812, "label": "Fe XVII", "row": 2},
    {"energy_keV": 0.826, "label": "Fe XVII", "row": 0},
    {"energy_keV": 0.853, "label": "Fe XVIII", "row": 0},
    {"energy_keV": 0.873, "label": "Fe XVIII blend", "row": 3},
    {"energy_keV": 0.905, "label": "Fe XIX / Ne IX", "row": 2},
    {"energy_keV": 0.917, "label": "Fe XIX / Ne IX", "row": 0},
    {"energy_keV": 0.965, "label": "Ne IX", "row": 4},
    {"energy_keV": 0.997, "label": "Ni XIX", "row": 0},
    {"energy_keV": 1.009, "label": "Fe XXI", "row": 0},
    {"energy_keV": 1.022, "label": "Ne X Lyα", "row": 3},
    {"energy_keV": 1.048, "label": "Fe XXII", "row": 2},
    {"energy_keV": 1.075, "label": "Fe XVIII / Ne IX", "row": 1},
    {"energy_keV": 1.102, "label": "Fe XXIII", "row": 0},
    {"energy_keV": 1.168, "label": "Fe XVII", "row": 4},
    {"energy_keV": 1.211, "label": "Ne X Lyβ", "row": 0},
    {"energy_keV": 1.277, "label": "Ne X", "row": 2},
    {"energy_keV": 1.343, "label": "Mg XI", "row": 0},
    {"energy_keV": 1.472, "label": "Mg XII Lyα", "row": 1},
    {"energy_keV": 1.579, "label": "Mg XI blend", "row": 0},
]


def file_sha256(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()


def array_sha256(values: np.ndarray) -> str:
    return hashlib.sha256(np.asarray(values, dtype="<f8").tobytes()).hexdigest()


def sha256_le_i8(values: np.ndarray) -> str:
    return hashlib.sha256(np.asarray(values, dtype=np.int64).tobytes()).hexdigest()


def extract_min30_region(manifest: dict, key: str) -> tuple[dict[str, np.ndarray], dict]:
    region = next(x for x in manifest["regions"] if x["key"] == key)
    variant = region["squde"]["grouping_variants"]["min30"]
    grid = variant["display_grid"]
    lo = np.asarray(grid["energy_lo_keV"], dtype=float)
    hi = np.asarray(grid["energy_hi_keV"], dtype=float)
    center = 0.5 * (lo + hi)
    rate = np.asarray(grid["data_rate_counts_s_keV"], dtype=float)
    control = np.asarray(grid["chandra_rmf_control_counts_s_keV"], dtype=float)
    mask = (center >= ENERGY_RANGE_KEV[0]) & (center <= ENERGY_RANGE_KEV[1])
    return {
        "energy_keV": center[mask].copy(),
        "data_rate_counts_s_keV": rate[mask].copy(),
        "chandra_rmf_control_counts_s_keV": control[mask].copy(),
        "grouping_vector_sha256_le_i8": variant["grouping_vector_sha256_le_i8"],
        "plotted_bins": int(mask.sum()),
    }, region


def regroup_min20(pha: Path) -> dict[str, np.ndarray]:
    clean()
    load_pha(1, str(pha))
    load_arf(1, str(SQUDE_ARF))
    load_rmf(1, str(SQUDE_RMF))
    set_analysis(1, "energy")
    notice_id(1, 0.3, 4.0)
    group_counts(1, 20)
    grouping = np.asarray(get_data(1).grouping, dtype=np.int64)
    data = copy.deepcopy(get_data_plot(1))
    lo = np.asarray(data.xlo, dtype=float)
    hi = np.asarray(data.xhi, dtype=float)
    center = 0.5 * (lo + hi)
    rate = np.asarray(data.y, dtype=float)
    mask = (center >= ENERGY_RANGE_KEV[0]) & (center <= ENERGY_RANGE_KEV[1])
    return {
        "energy_keV": center[mask].copy(),
        "data_rate_counts_s_keV": rate[mask].copy(),
        "grouping_vector_sha256_le_i8": sha256_le_i8(grouping),
        "plotted_bins": int(mask.sum()),
    }


def build_min20_center_control(manifest: dict) -> np.ndarray:
    """Rebin Center eight-epoch Chandra-RMF control to Center min20 grid.

    The model_grid chandra_rmf_control_counts_s_keV array is on a common
    fine grid. We conservatively rebin it to the Center min20 display bin
    edges using the same density-conserving rule as the min30 contract.
    """
    region = next(x for x in manifest["regions"] if x["key"] == "v40_center")
    min30 = region["squde"]["grouping_variants"]["min30"]
    fine_lo = np.asarray(min30["model_grid"]["energy_lo_keV"], dtype=float)
    fine_hi = np.asarray(min30["model_grid"]["energy_hi_keV"], dtype=float)
    fine_rate = np.asarray(
        min30["model_grid"]["chandra_rmf_control_counts_s_keV"], dtype=float
    )
    # Rebuild min20 display edges from the actual Center PHA regroup.
    clean()
    load_pha(1, str(CENTER_PHA))
    load_arf(1, str(SQUDE_ARF))
    load_rmf(1, str(SQUDE_RMF))
    set_analysis(1, "energy")
    notice_id(1, 0.3, 4.0)
    group_counts(1, 20)
    data = copy.deepcopy(get_data_plot(1))
    disp_lo = np.asarray(data.xlo, dtype=float)
    disp_hi = np.asarray(data.xhi, dtype=float)
    # Density-conserving rebin: integral of rate over each display bin.
    out = np.zeros(len(disp_lo), dtype=float)
    for i, (lo, hi) in enumerate(zip(disp_lo, disp_hi)):
        sel = (fine_lo >= lo - 1e-12) & (fine_hi <= hi + 1e-12)
        if sel.any():
            width = np.maximum(fine_hi[sel] - fine_lo[sel], 1e-30)
            out[i] = float(np.sum(fine_rate[sel] * width) / max(hi - lo, 1e-30))
    center20 = 0.5 * (disp_lo + disp_hi)
    mask = (center20 >= ENERGY_RANGE_KEV[0]) & (center20 <= ENERGY_RANGE_KEV[1])
    return out[mask].copy(), center20[mask].copy()


def load_curves() -> tuple[dict[str, Any], dict, dict]:
    if file_sha256(SOURCE_MANIFEST) != SOURCE_MANIFEST_SHA256:
        raise RuntimeError("Regional source-manifest hash mismatch")
    if file_sha256(LINE_EVIDENCE) != LINE_EVIDENCE_SHA256:
        raise RuntimeError("Bounded AtomDB line-evidence hash mismatch")
    for label, p, expected in [
        ("CENTER_PHA", CENTER_PHA, CENTER_PHA_SHA256),
        ("NORTH_PHA", NORTH_PHA, NORTH_PHA_SHA256),
        ("SQUDE_ARF", SQUDE_ARF, SQUDE_ARF_SHA256),
        ("SQUDE_RMF", SQUDE_RMF, SQUDE_RMF_SHA256),
    ]:
        if file_sha256(p) != expected:
            raise RuntimeError(f"{label} hash mismatch")
    manifest = json.loads(SOURCE_MANIFEST.read_text(encoding="utf-8"))
    if manifest.get("version") != "joint-center-v6-complete-grouping-contract":
        raise RuntimeError("Unexpected regional manifest version")
    center30, center_meta = extract_min30_region(manifest, "v40_center")
    north30, north_meta = extract_min30_region(manifest, "v80_north")
    # Verify min30 grouping from PHA replay.
    regrouped_center30 = regroup_min20(CENTER_PHA)
    # regroup_min20 uses group_counts(20); verify min30 separately below.
    clean()
    load_pha(1, str(CENTER_PHA))
    load_arf(1, str(SQUDE_ARF))
    load_rmf(1, str(SQUDE_RMF))
    set_analysis(1, "energy")
    notice_id(1, 0.3, 4.0)
    group_counts(1, 30)
    g30 = np.asarray(get_data(1).grouping, dtype=np.int64)
    if sha256_le_i8(g30) != center30["grouping_vector_sha256_le_i8"]:
        raise RuntimeError("Center min30 grouping-vector hash mismatch")
    clean()
    load_pha(1, str(NORTH_PHA))
    load_arf(1, str(SQUDE_ARF))
    load_rmf(1, str(SQUDE_RMF))
    set_analysis(1, "energy")
    notice_id(1, 0.3, 4.0)
    group_counts(1, 30)
    g30n = np.asarray(get_data(1).grouping, dtype=np.int64)
    if sha256_le_i8(g30n) != north30["grouping_vector_sha256_le_i8"]:
        raise RuntimeError("North min30 grouping-vector hash mismatch")
    # min20 regrouping.
    center20 = regroup_min20(CENTER_PHA)
    north20 = regroup_min20(NORTH_PHA)
    control20, center20_energy = build_min20_center_control(manifest)
    # Verify min20 energy grid matches the center mock grid.
    if not np.allclose(center20["energy_keV"], center20_energy, atol=1e-9):
        raise RuntimeError("Center min20 mock and control energy grids differ")
    return {
        "center30": center30,
        "north30": north30,
        "center20": center20,
        "north20": north20,
        "center_control20": {
            "energy_keV": center20_energy,
            "chandra_rmf_control_counts_s_keV": control20,
        },
    }, center_meta, north_meta


def annotate_reference_lines(
    ax: plt.Axes, label_ax: plt.Axes, width: float
) -> None:
    narrow = width < 5
    fontsize = 3.0 if narrow else 4.2
    leader_lw = 0.45 if narrow else 0.60
    for record in LINE_LABELS:
        energy = record["energy_keV"]
        ax.axvline(
            energy,
            color="#A0A0A0",
            linewidth=leader_lw,
            linestyle=":",
            alpha=0.42,
            zorder=1,
        )
        row_y = 0.02 + 0.19 * record["row"]
        label_ax.vlines(
            energy,
            0.0,
            max(0.0, row_y - 0.015),
            color="#8A8A8A",
            linewidth=leader_lw,
            linestyle=":",
            alpha=0.70,
            zorder=1,
        )
        label_text = f"{record['label']} {energy:.3f} keV"
        label_ax.text(
            energy,
            row_y,
            label_text,
            rotation=90,
            fontsize=fontsize,
            color="#303640",
            ha="left",
            va="bottom",
            alpha=0.96,
            clip_on=False,
            linespacing=0.92,
            zorder=2,
        )
    label_ax.set_xlim(*ENERGY_RANGE_KEV)
    label_ax.set_ylim(0.0, 1.0)
    label_ax.axis("off")


def render_panel(
    curves: dict,
    panel_key: str,
    path: Path,
    width: float,
) -> dict[str, Any]:
    dpi = 320
    narrow = width < 5
    height = 4.50 if narrow else 5.20
    fig = plt.figure(figsize=(width, height), dpi=dpi)
    grid = fig.add_gridspec(
        2,
        1,
        height_ratios=[1.90 if narrow else 1.70, 3.0],
        hspace=0.025,
        left=0.16 if narrow else 0.10,
        right=0.985,
        bottom=0.13 if narrow else 0.12,
        top=0.90,
    )
    label_ax = fig.add_subplot(grid[0])
    ax = fig.add_subplot(grid[1], sharex=label_ax)
    if panel_key == "min30":
        ce = curves["center30"]["energy_keV"]
        cr = curves["center30"]["data_rate_counts_s_keV"]
        ne = curves["north30"]["energy_keV"]
        nr = curves["north30"]["data_rate_counts_s_keV"]
        ctrl = curves["center30"]["chandra_rmf_control_counts_s_keV"]
        label_suffix = "min30"
    else:
        ce = curves["center20"]["energy_keV"]
        cr = curves["center20"]["data_rate_counts_s_keV"]
        ne = curves["north20"]["energy_keV"]
        nr = curves["north20"]["data_rate_counts_s_keV"]
        ctrl = curves["center_control20"]["chandra_rmf_control_counts_s_keV"]
        label_suffix = "min20"
    ax.plot(
        ce, cr, color="royalblue",
        lw=0.76 if narrow else 1.00, alpha=0.98, zorder=4,
        label=f"Center SQUDE mock (50 ks Poisson; {label_suffix})",
    )
    ax.plot(
        ne, nr, color="black",
        lw=0.68 if narrow else 0.90, alpha=0.88, zorder=3,
        label=f"North SQUDE mock (50 ks Poisson; {label_suffix})",
    )
    ax.plot(
        ce, ctrl, color="#D62728",
        lw=1.25 if narrow else 1.65, alpha=1.0, zorder=5,
        label="Center Chandra-RMF control (fixed SQUDE ARF)",
    )
    y_values = np.concatenate([cr, nr, ctrl])
    ax.set_xlim(*ENERGY_RANGE_KEV)
    ax.set_ylim(0.0, float(np.nanmax(y_values) * 1.08))
    annotate_reference_lines(ax, label_ax, width)
    ax.set_xlabel("Energy (keV)")
    ax.set_ylabel(r"Counts s$^{-1}$ keV$^{-1}$")
    title = (
        f"M82 Center/North/CCD — {label_suffix}"
        if narrow
        else f"M82 Center and North SQUDE mocks vs Center CCD resolution — grouping {label_suffix}"
    )
    fig.suptitle(title, fontsize=7.0 if narrow else 11.8, y=0.965)
    ax.legend(
        loc="upper right",
        fontsize=4.15 if narrow else 6.8,
        title=("connected data; red=Center control" if narrow else None),
        title_fontsize=4.0 if narrow else None,
        frameon=True,
        framealpha=0.89 if narrow else 0.83,
        facecolor="white",
        edgecolor="none",
        handlelength=2.4,
    )
    ax.grid(alpha=0.14)
    ax.minorticks_on()
    ax.tick_params(which="both", direction="in", top=True, right=True)
    position = ax.get_position()
    label_position = label_ax.get_position()
    geometry = {
        "figsize_inches": [float(width), float(height)],
        "dpi": dpi,
        "canvas_pixels": [int(width * dpi), int(height * dpi)],
        "axes_bbox_fraction": [
            float(position.x0), float(position.y0),
            float(position.width), float(position.height),
        ],
        "label_axes_bbox_fraction": [
            float(label_position.x0), float(label_position.y0),
            float(label_position.width), float(label_position.height),
        ],
        "xlim_keV": [float(v) for v in ax.get_xlim()],
        "ylim_counts_s_keV": [float(v) for v in ax.get_ylim()],
    }
    with plt.rc_context({"savefig.bbox": None}):
        fig.savefig(path, dpi=dpi, bbox_inches=None)
    plt.close(fig)
    return geometry


def manifest_array(values: np.ndarray) -> dict[str, Any]:
    return {
        "length": int(len(values)),
        "sha256_le_f8": array_sha256(values),
        "values": [float(v) for v in values],
    }


def main(output_dir: Path) -> None:
    requested = output_dir.expanduser()
    if not requested.is_absolute():
        requested = Path.cwd() / requested
    if os.path.lexists(requested):
        raise FileExistsError(f"Refusing occupied lexical path: {requested}")
    resolved = requested.resolve(strict=False)
    if os.path.lexists(resolved):
        raise FileExistsError(f"Refusing occupied resolved path: {resolved}")
    resolved.parent.mkdir(parents=True, exist_ok=True)
    staging = resolved.parent / f".{resolved.name}.tmp-{os.getpid()}-{uuid.uuid4().hex}"
    staging.mkdir()
    try:
        curves, center_meta, north_meta = load_curves()
        outputs: dict[str, Any] = {}
        for panel_key in ("min30", "min20"):
            for role, width in (("1col", 3.35), ("2col", 7.00)):
                fname = f"{PREFIX}_{panel_key}_{role}.png"
                path = staging / fname
                outputs[f"{panel_key}_{role}"] = {
                    "path": fname,
                    "geometry": render_panel(curves, panel_key, path, width),
                    "sha256": file_sha256(path),
                }
        center_control = center_meta["squde"]["resolution_control"]
        manifest = {
            "version": "center-north-min20-vs-min30-slider-v1",
            "scientific_role": "grouping-sensitivity slider comparison for the Center/North/CCD three-line figure",
            "slider_contract": {
                "left_panel": "min30 (group_counts 30): exact regional-manifest arrays",
                "right_panel": "min20 (group_counts 20): same seeded PHAs regrouped; same Center source, same eight epoch Chandra RMFs, conservatively rebinned to the min20 display grid",
                "differs_only_in": "grouping threshold",
                "shared": "25 keV reference labels, colors, legend roles, energy range, y-axis semantics, line-label producer and bounded-evidence pins",
                "normalization": "absolute counts s^-1 keV^-1; no area, peak, or integral renormalization",
            },
            "curve_contract": {
                "center_blue": "Center 40x80 arcsec seeded 50 ks SQUDE mock data on its native grouped display grid",
                "north_black": "North 80x80 arcsec seeded 50 ks SQUDE mock data on its native grouped display grid",
                "center_red": "Center source folded through each epoch-specific Chandra RMF at fixed SQUDE ARF, then exposure-weighted and conservatively projected to the Center display grid",
                "grid_policy": "Center blue/red share the Center grid; North retains its distinct native grid; min30 and min20 each have their own display grids",
            },
            "scope_limitations": [
                "Center and North have different apertures and source populations; line heights are not same-area surface-brightness measurements",
                "blue and black are separate Poisson realizations with different grouped bins",
                "red is a deterministic Center response control, not Chandra data",
                "line labels are AtomDB/SQUDE catalog-pinned energy-reference guides, not detections or unique deblended fit assignments",
                "all label text is isolated in a dedicated annotation band and uses keV to match the x axis",
                "figure is not fit-quality or physical-parameter evidence",
            ],
            "producer": {
                "path": "scripts/make_m82_center_north_min20_vs_min30_slider.py",
                "sha256": file_sha256(Path(__file__).resolve()),
                "replay_command": "python -B scripts/make_m82_center_north_min20_vs_min30_slider.py --output-dir <new-nonexistent-output-dir>",
            },
            "source_manifest": {
                "path": str(SOURCE_MANIFEST.relative_to(ROOT)),
                "expected_sha256": SOURCE_MANIFEST_SHA256,
                "observed_sha256": file_sha256(SOURCE_MANIFEST),
                "version": "joint-center-v6-complete-grouping-contract",
            },
            "pha_pins": {
                "center": {"path": str(CENTER_PHA.relative_to(ROOT)), "sha256": CENTER_PHA_SHA256},
                "north": {"path": str(NORTH_PHA.relative_to(ROOT)), "sha256": NORTH_PHA_SHA256},
                "squde_arf": {"path": str(SQUDE_ARF.relative_to(ROOT)), "sha256": SQUDE_ARF_SHA256},
                "squde_rmf": {"path": str(SQUDE_RMF.relative_to(ROOT)), "sha256": SQUDE_RMF_SHA256},
            },
            "regions": {
                "center": {
                    "key": center_meta["key"],
                    "geometry": center_meta["geometry"],
                    "seed": center_meta["squde"]["seed"],
                    "exposure_s": center_meta["squde"]["exposure_s"],
                    "min30_plotted_bins": int(len(curves["center30"]["energy_keV"])),
                    "min20_plotted_bins": int(len(curves["center20"]["energy_keV"])),
                },
                "north": {
                    "key": north_meta["key"],
                    "geometry": north_meta["geometry"],
                    "seed": north_meta["squde"]["seed"],
                    "exposure_s": north_meta["squde"]["exposure_s"],
                    "min30_plotted_bins": int(len(curves["north30"]["energy_keV"])),
                    "min20_plotted_bins": int(len(curves["north20"]["energy_keV"])),
                },
            },
            "center_control_provenance": {
                "operator": center_control["operator"],
                "source_semantics": center_control["source_semantics"],
                "aggregation": center_control["aggregation"],
                "weight_basis": center_control["weight_basis"],
                "weight_sum": center_control["weight_sum"],
                "total_chandra_exposure_s": center_control["total_chandra_exposure_s"],
                "fixed_arf_sha256": center_control["fixed_arf_sha256"],
                "epochs": len(center_control["epochs"]),
            },
            "line_labels": {
                "sources": [
                    {"path": "atomdb/apec_v3.1.3_linelist.fits", "sha256": ATOMDB_LINE_LIST_SHA256, "role": "AtomDB 3.1.3 transition energies and temperature-dependent emissivities"},
                    {"path": "SQUDE_propsoal/M82_data/mos1/1_fake_spectra.py", "sha256": SQUDE_LINE_MARKER_SOURCE_SHA256, "role": "project Fe-L/O/Ne/Mg representative marker implementation"},
                    {"path": "M31Center/rgs_emission_lines.py", "sha256": RGS_LABEL_SOURCE_SHA256, "role": "RGS-style annotation provenance"},
                ],
                "bounded_evidence": {"path": str(LINE_EVIDENCE.relative_to(ROOT)), "sha256": LINE_EVIDENCE_SHA256, "labels": 25},
                "selection_rule": "representative AtomDB/SQUDE transition groups cross-matched to deterministic Center/North SQUDE-RMF model peaks; not a complete emissivity table",
                "axis_and_label_unit": "keV",
                "labels": LINE_LABELS,
                "user_requested_coverage": [
                    {"requested_near_keV": 0.78, "anchor_keV": 0.774, "assignment": "Fe XVIII / O VIII blend"},
                    {"requested_near_keV": 0.87, "anchor_keV": 0.873, "assignment": "Fe XVIII blend"},
                    {"requested_near_keV": 0.99, "anchor_keV": 0.997, "assignment": "Ni XIX; adjacent Fe XXI is separately marked at 1.009 keV"},
                    {"requested_near_keV": 1.08, "anchor_keV": 1.075, "assignment": "Fe XVIII / Ne IX blend"},
                    {"requested_near_keV": 1.21, "anchor_keV": 1.211, "assignment": "Ne X Ly-beta"},
                ],
                "n_vii_boundary_exclusion": "0.500 keV anchor omitted because it lies on the displayed left boundary",
                "artist_contract": "25 light full-height guides in the spectrum panel plus 25 energy labels in a separate five-row top annotation band",
            },
            "runtime": {
                "python": sys.version.split()[0],
                "numpy": np.__version__,
                "matplotlib": matplotlib.__version__,
                "scienceplots": package_version("SciencePlots"),
            },
            "colors": {
                "center_mock": {"name": "royalblue", "hex": "#4169E1"},
                "north_mock": {"name": "black", "hex": "#000000"},
                "center_control": {"name": "red", "hex": "#D62728"},
            },
            "arrays": {
                "center30_energy_keV": manifest_array(curves["center30"]["energy_keV"]),
                "center30_mock_rate": manifest_array(curves["center30"]["data_rate_counts_s_keV"]),
                "center30_control": manifest_array(curves["center30"]["chandra_rmf_control_counts_s_keV"]),
                "north30_energy_keV": manifest_array(curves["north30"]["energy_keV"]),
                "north30_mock_rate": manifest_array(curves["north30"]["data_rate_counts_s_keV"]),
                "center20_energy_keV": manifest_array(curves["center20"]["energy_keV"]),
                "center20_mock_rate": manifest_array(curves["center20"]["data_rate_counts_s_keV"]),
                "center20_control": manifest_array(curves["center_control20"]["chandra_rmf_control_counts_s_keV"]),
                "north20_energy_keV": manifest_array(curves["north20"]["energy_keV"]),
                "north20_mock_rate": manifest_array(curves["north20"]["data_rate_counts_s_keV"]),
            },
            "grouping_pins": {
                "center30_sha256_le_i8": curves["center30"]["grouping_vector_sha256_le_i8"],
                "north30_sha256_le_i8": curves["north30"]["grouping_vector_sha256_le_i8"],
                "center20_sha256_le_i8": curves["center20"]["grouping_vector_sha256_le_i8"],
                "north20_sha256_le_i8": curves["north20"]["grouping_vector_sha256_le_i8"],
            },
            "artist_counts": {
                "scientific_lines_per_panel": 3,
                "reference_guides_per_panel": len(LINE_LABELS),
                "reference_text_labels_per_panel": len(LINE_LABELS),
                "annotation_leader_collections_per_panel": len(LINE_LABELS),
                "labels_over_data_panel": 0,
                "errorbars": 0,
                "markers": 0,
                "panels": 2,
            },
            "outputs": outputs,
        }
        manifest_path = staging / f"{PREFIX}_manifest.json"
        manifest_path.write_text(
            json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
            encoding="utf-8",
        )
        if os.path.lexists(requested) or os.path.lexists(resolved):
            raise FileExistsError("Output path appeared during build")
        os.rename(staging, resolved)
    except BaseException:
        shutil.rmtree(staging, ignore_errors=True)
        raise
    print(f"Wrote {len(outputs)} panel PNGs + manifest to {resolved}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument(
        "--output-dir",
        type=Path,
        default=DEFAULT_OUTPUT,
        help="new, nonexistent output directory",
    )
    main(parser.parse_args().output_dir)
