#!/usr/bin/env python3
"""Auditable leave-GFOD2-out sensitivity analysis for the v2.3 benchmark."""

from __future__ import annotations

import argparse
import hashlib
import json
import platform
import shutil
import sys
from pathlib import Path

import numpy as np
import pandas as pd


ROOT = Path(__file__).resolve().parents[1]
SCORES = ROOT / "results/evidence_upgrade_v23/p0_statistical_audit/extended_alpha_crossfit/realizations"
CANONICAL_TWO_STAGE = (
    ROOT
    / "results/evidence_upgrade_v23/p0_statistical_audit/extended_alpha_crossfit"
    / "corrected_two_stage_uncertainty.tsv"
)
MODULE_DIR = ROOT / "results/evidence_upgrade_v23/p0_statistical_audit/response_module_block_bootstrap"
MODULES = MODULE_DIR / "response_profile_modules_54x40.tsv"
CANONICAL_MODULE = MODULE_DIR / "response_module_block_uncertainty.tsv"
CANDIDATE_STATUS = ROOT / "results/candidate_evidence_transition_v09/candidate_current_status.tsv"
DEFAULT_OUT = ROOT / "results/gfod2_exclusion_sensitivity_v01"

SEEDS = list(range(20260717, 20260737))
TARGET_GENE = "GFOD2"
REPLICATES = 10_000
CANONICAL_TWO_STAGE_SEED = 20260738
CANONICAL_MODULE_SEED = 20260739
EXCLUSION_TWO_STAGE_SEED = 20260740
EXCLUSION_MODULE_SEED = 20260741
METRICS = [
    "rmse_difference_vs_zero",
    "rmse_difference_vs_train_mean",
    "raw_cosine_difference_vs_train_mean",
    "perturbation_specific_residual_cosine",
]
RMSE_METRICS = {"rmse_difference_vs_zero", "rmse_difference_vs_train_mean"}
SERIALIZED_NUMERIC_TOLERANCE = 1e-15


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--out-dir", type=Path, default=DEFAULT_OUT)
    parser.add_argument(
        "--overwrite",
        action="store_true",
        help="Replace an existing output directory. Intended for deterministic verification reruns.",
    )
    return parser.parse_args()


def sha256(path: Path) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as handle:
        for block in iter(lambda: handle.read(1024 * 1024), b""):
            digest.update(block)
    return digest.hexdigest()


def write_tsv(path: Path, frame: pd.DataFrame) -> None:
    frame.to_csv(path, sep="\t", index=False, lineterminator="\n")


def interval_class(metric: str, low: float, high: float) -> str:
    if metric in RMSE_METRICS:
        if high < 0:
            return "favors_model"
        if low > 0:
            return "favors_comparator"
        return "mixed_crosses_zero"
    if low > 0:
        return "favors_model"
    if high < 0:
        return "favors_comparator"
    return "mixed_crosses_zero"


def serialized_numeric_match(observed: float, expected: float) -> bool:
    """Allow only sub-serialization-roundoff differences in canonical TSV values."""
    return abs(observed - expected) <= SERIALIZED_NUMERIC_TOLERANCE


def two_stage_bootstrap(
    matrix: np.ndarray, replicates: int, rng: np.random.Generator
) -> np.ndarray:
    """Match the corrected v2.3 two-stage implementation exactly."""
    n_realizations, n_targets = matrix.shape
    draws = np.empty(replicates, dtype=np.float64)
    for start in range(0, replicates, 20):
        stop = min(start + 20, replicates)
        batch = stop - start
        sampled_realizations = rng.integers(0, n_realizations, size=(batch, n_realizations))
        totals = np.zeros(batch, dtype=np.float64)
        for position in range(n_realizations):
            target_indices = rng.integers(0, n_targets, size=(batch, n_targets))
            sampled = matrix[sampled_realizations[:, position, None], target_indices]
            totals += sampled.mean(axis=1)
        draws[start:stop] = totals / n_realizations
    return draws


def module_bootstrap(
    matrix: np.ndarray,
    module_labels: np.ndarray,
    replicates: int,
    rng: np.random.Generator,
) -> np.ndarray:
    """Sample the predeclared response modules without target-level resampling."""
    module_ids = np.sort(np.unique(module_labels))
    module_means = np.empty((matrix.shape[0], len(module_ids)), dtype=np.float64)
    for index, module_id in enumerate(module_ids):
        module_means[:, index] = matrix[:, module_labels == module_id].mean(axis=1)
    draws = np.empty(replicates, dtype=np.float64)
    for replicate in range(replicates):
        sampled_realizations = rng.integers(0, matrix.shape[0], size=matrix.shape[0])
        sampled_modules = rng.integers(0, len(module_ids), size=len(module_ids))
        draws[replicate] = module_means[np.ix_(sampled_realizations, sampled_modules)].mean()
    return draws


def load_score_matrices() -> tuple[list[str], dict[str, np.ndarray], list[Path]]:
    frames: list[pd.DataFrame] = []
    paths: list[Path] = []
    reference_genes: list[str] | None = None
    for seed in SEEDS:
        path = SCORES / f"seed_{seed}" / "target_scores.tsv"
        frame = pd.read_csv(path, sep="\t").sort_values("gene").reset_index(drop=True)
        genes = frame["gene"].astype(str).tolist()
        if frame["gene"].nunique() != len(frame):
            raise ValueError(f"Duplicate target genes in {path}")
        if reference_genes is None:
            reference_genes = genes
        elif genes != reference_genes:
            raise ValueError(f"Target order/set mismatch in {path}")
        missing = [metric for metric in METRICS if metric not in frame.columns]
        if missing:
            raise ValueError(f"Missing metrics in {path}: {missing}")
        frames.append(frame)
        paths.append(path)
    if reference_genes is None or len(reference_genes) != 2160:
        raise ValueError("Expected exactly 2,160 common targets")
    if reference_genes.count(TARGET_GENE) != 1:
        raise ValueError(f"Expected exactly one {TARGET_GENE} target")
    matrices = {
        metric: np.vstack([frame[metric].to_numpy(dtype=float) for frame in frames])
        for metric in METRICS
    }
    return reference_genes, matrices, paths


def summarize_point_estimates(
    matrices: dict[str, np.ndarray], keep_mask: np.ndarray
) -> tuple[pd.DataFrame, pd.DataFrame]:
    rows: list[dict[str, object]] = []
    target_rows: list[dict[str, object]] = []
    for metric, matrix in matrices.items():
        full_mean = float(matrix.mean())
        full_realization_means = matrix.mean(axis=1)
        excluded = matrix[:, keep_mask]
        excluded_mean = float(excluded.mean())
        excluded_realization_means = excluded.mean(axis=1)
        target_values = matrix[:, ~keep_mask].reshape(-1)
        rows.extend(
            [
                {
                    "scope": "all_2160_targets",
                    "excluded_gene": "",
                    "metric": metric,
                    "n_realizations": matrix.shape[0],
                    "n_targets": matrix.shape[1],
                    "mean_across_realizations_and_targets": full_mean,
                    "between_realization_sd": float(full_realization_means.std(ddof=1)),
                    "delta_vs_all_targets": 0.0,
                },
                {
                    "scope": "leave_GFOD2_out",
                    "excluded_gene": TARGET_GENE,
                    "metric": metric,
                    "n_realizations": excluded.shape[0],
                    "n_targets": excluded.shape[1],
                    "mean_across_realizations_and_targets": excluded_mean,
                    "between_realization_sd": float(excluded_realization_means.std(ddof=1)),
                    "delta_vs_all_targets": excluded_mean - full_mean,
                },
            ]
        )
        target_rows.append(
            {
                "gene": TARGET_GENE,
                "metric": metric,
                "n_realizations": len(target_values),
                "mean": float(target_values.mean()),
                "sd": float(target_values.std(ddof=1)),
                "minimum": float(target_values.min()),
                "maximum": float(target_values.max()),
            }
        )
    return pd.DataFrame(rows), pd.DataFrame(target_rows)


def summarize_two_stage(
    matrices: dict[str, np.ndarray], keep_mask: np.ndarray
) -> tuple[pd.DataFrame, list[dict[str, object]]]:
    canonical = pd.read_csv(CANONICAL_TWO_STAGE, sep="\t").set_index("metric")
    rows: list[dict[str, object]] = []
    checks: list[dict[str, object]] = []
    for scope, mask, seed in [
        ("all_2160_targets", np.ones_like(keep_mask, dtype=bool), CANONICAL_TWO_STAGE_SEED),
        ("leave_GFOD2_out", keep_mask, EXCLUSION_TWO_STAGE_SEED),
    ]:
        rng = np.random.default_rng(seed)
        for metric, matrix in matrices.items():
            scoped = matrix[:, mask]
            draws = two_stage_bootstrap(scoped, REPLICATES, rng)
            low = float(np.quantile(draws, 0.025))
            high = float(np.quantile(draws, 0.975))
            rows.append(
                {
                    "scope": scope,
                    "excluded_gene": "" if scope == "all_2160_targets" else TARGET_GENE,
                    "metric": metric,
                    "n_realizations": scoped.shape[0],
                    "n_targets_per_realization": scoped.shape[1],
                    "mean_across_realizations_and_targets": float(scoped.mean()),
                    "two_stage_mean_ci95_low": low,
                    "two_stage_mean_ci95_high": high,
                    "interval_classification": interval_class(metric, low, high),
                    "bootstrap_replicates": REPLICATES,
                    "bootstrap_seed": seed,
                }
            )
            if scope == "all_2160_targets":
                expected = canonical.loc[metric]
                checks.append(
                    {
                        "gate": f"canonical_two_stage_serialized_numeric_match_{metric}",
                        "status": (
                            "PASS"
                            if serialized_numeric_match(
                                low, float(expected["two_stage_mean_ci95_low"])
                            )
                            and serialized_numeric_match(
                                high, float(expected["two_stage_mean_ci95_high"])
                            )
                            and serialized_numeric_match(
                                float(scoped.mean()),
                                float(expected["mean_across_realizations"]),
                            )
                            else "FAIL"
                        ),
                        "detail": (
                            f"recomputed=[{low:.17g},{high:.17g}]; "
                            f"canonical=[{float(expected['two_stage_mean_ci95_low']):.17g},"
                            f"{float(expected['two_stage_mean_ci95_high']):.17g}]; "
                            f"absolute_tolerance={SERIALIZED_NUMERIC_TOLERANCE:g}"
                        ),
                    }
                )
    return pd.DataFrame(rows), checks


def summarize_module_bootstrap(
    genes: list[str],
    matrices: dict[str, np.ndarray],
    keep_mask: np.ndarray,
) -> tuple[pd.DataFrame, list[dict[str, object]], int]:
    module_frame = pd.read_csv(MODULES, sep="\t").set_index("gene")
    if set(module_frame.index.astype(str)) != set(genes):
        raise ValueError("Response-module target set does not match score matrices")
    labels = module_frame.loc[genes, "response_profile_module"].to_numpy(dtype=int)
    target_module = int(labels[~keep_mask][0])
    canonical = pd.read_csv(CANONICAL_MODULE, sep="\t").set_index("metric")
    rows: list[dict[str, object]] = []
    checks: list[dict[str, object]] = []
    for scope, mask, seed in [
        ("all_2160_targets", np.ones_like(keep_mask, dtype=bool), CANONICAL_MODULE_SEED),
        ("leave_GFOD2_out", keep_mask, EXCLUSION_MODULE_SEED),
    ]:
        scoped_labels = labels[mask]
        sizes = pd.Series(scoped_labels).value_counts()
        rng = np.random.default_rng(seed)
        for metric, matrix in matrices.items():
            scoped = matrix[:, mask]
            draws = module_bootstrap(scoped, scoped_labels, REPLICATES, rng)
            low = float(np.quantile(draws, 0.025))
            high = float(np.quantile(draws, 0.975))
            module_ids = np.sort(np.unique(scoped_labels))
            module_means = np.column_stack(
                [scoped[:, scoped_labels == module_id].mean(axis=1) for module_id in module_ids]
            )
            equal_module_mean = float(module_means.mean())
            rows.append(
                {
                    "scope": scope,
                    "excluded_gene": "" if scope == "all_2160_targets" else TARGET_GENE,
                    "metric": metric,
                    "n_realizations": scoped.shape[0],
                    "n_response_profile_modules": len(module_ids),
                    "minimum_module_size": int(sizes.min()),
                    "maximum_module_size": int(sizes.max()),
                    "GFOD2_original_module": target_module,
                    "GFOD2_module_size_after_scope": int(sizes.loc[target_module]),
                    "equal_module_weight_mean": equal_module_mean,
                    "target_weight_mean": float(scoped.mean()),
                    "module_block_ci95_low": low,
                    "module_block_ci95_high": high,
                    "interval_classification": interval_class(metric, low, high),
                    "bootstrap_replicates": REPLICATES,
                    "bootstrap_seed": seed,
                }
            )
            if scope == "all_2160_targets":
                expected = canonical.loc[metric]
                checks.append(
                    {
                        "gate": f"canonical_module_serialized_numeric_match_{metric}",
                        "status": (
                            "PASS"
                            if serialized_numeric_match(
                                low, float(expected["module_block_ci95_low"])
                            )
                            and serialized_numeric_match(
                                high, float(expected["module_block_ci95_high"])
                            )
                            and serialized_numeric_match(
                                equal_module_mean,
                                float(expected["mean_across_realizations_and_targets"]),
                            )
                            else "FAIL"
                        ),
                        "detail": (
                            f"recomputed=[{low:.17g},{high:.17g}]; "
                            f"canonical=[{float(expected['module_block_ci95_low']):.17g},"
                            f"{float(expected['module_block_ci95_high']):.17g}]; "
                            f"absolute_tolerance={SERIALIZED_NUMERIC_TOLERANCE:g}"
                        ),
                    }
                )
    return pd.DataFrame(rows), checks, target_module


def candidate_action_summary() -> tuple[pd.DataFrame, list[dict[str, object]]]:
    status = pd.read_csv(CANDIDATE_STATUS, sep="\t")
    if status["gene"].astype(str).tolist().count(TARGET_GENE) != 1:
        raise ValueError(f"Expected exactly one {TARGET_GENE} row in candidate status")
    active = status.loc[status["gene"] != TARGET_GENE].copy()
    action_order = (
        status[["current_action", "current_display_label", "current_action_order"]]
        .drop_duplicates()
        .sort_values(["current_action_order", "current_action"])
    )
    rows: list[dict[str, object]] = []
    for row in action_order.itertuples(index=False):
        rows.append(
            {
                "current_action": row.current_action,
                "current_display_label": row.current_display_label,
                "current_action_order": int(row.current_action_order),
                "n_candidates_all_21": int((status["current_action"] == row.current_action).sum()),
                "n_candidates_leave_GFOD2_out": int((active["current_action"] == row.current_action).sum()),
                "numeric_current_rank_assigned": False,
            }
        )
    checks = [
        {
            "gate": "candidate_count_after_exclusion",
            "status": "PASS" if len(status) == 21 and len(active) == 20 else "FAIL",
            "detail": f"all={len(status)}; leave_GFOD2_out={len(active)}",
        },
        {
            "gate": "no_replacement_top_gene",
            "status": (
                "PASS"
                if active["current_rank"].isna().all()
                and active["rank_display_policy"].eq("action_then_gene_no_numeric_current_rank").all()
                else "FAIL"
            ),
            "detail": "Remaining candidates retain action-then-gene display; no numeric current rank is assigned.",
        },
    ]
    return pd.DataFrame(rows), checks


def build_claim_gates(
    two_stage: pd.DataFrame,
    module: pd.DataFrame,
    reproduction_checks: list[dict[str, object]],
    candidate_checks: list[dict[str, object]],
) -> pd.DataFrame:
    rows = reproduction_checks + candidate_checks
    for method, frame, low_col, high_col in [
        ("two_stage", two_stage, "two_stage_mean_ci95_low", "two_stage_mean_ci95_high"),
        ("module_block", module, "module_block_ci95_low", "module_block_ci95_high"),
    ]:
        for metric in METRICS:
            full = frame.loc[
                (frame["scope"] == "all_2160_targets") & (frame["metric"] == metric)
            ].iloc[0]
            excluded = frame.loc[
                (frame["scope"] == "leave_GFOD2_out") & (frame["metric"] == metric)
            ].iloc[0]
            expected_full = interval_class(metric, float(full[low_col]), float(full[high_col]))
            observed_excluded = interval_class(
                metric, float(excluded[low_col]), float(excluded[high_col])
            )
            rows.append(
                {
                    "gate": f"direction_preserved_{method}_{metric}",
                    "status": "PASS" if expected_full == observed_excluded else "FAIL",
                    "detail": (
                        f"all={expected_full}; leave_GFOD2_out={observed_excluded}; "
                        f"leave-out CI=[{float(excluded[low_col]):.6f},{float(excluded[high_col]):.6f}]"
                    ),
                }
            )
    rows.extend(
        [
            {
                "gate": "claim_boundary_not_biological_validation",
                "status": "PASS",
                "detail": (
                    "This is a benchmark and queue-robustness analysis. It neither validates nor refutes "
                    "the biological importance of GFOD2 in DMD/NMD."
                ),
            },
            {
                "gate": "external_negative_evidence_not_inferred",
                "status": "PASS",
                "detail": (
                    "Author-reported cross-project findings remain unaudited until effect estimates, "
                    "uncertainty, sample definition, code, and provenance are imported."
                ),
            },
        ]
    )
    return pd.DataFrame(rows)


def build_readme(
    point: pd.DataFrame,
    two_stage: pd.DataFrame,
    module: pd.DataFrame,
    target_module: int,
) -> str:
    def metric_row(frame: pd.DataFrame, metric: str) -> pd.Series:
        return frame.loc[
            (frame["scope"] == "leave_GFOD2_out") & (frame["metric"] == metric)
        ].iloc[0]

    rmse_zero = metric_row(two_stage, "rmse_difference_vs_zero")
    rmse_mean = metric_row(two_stage, "rmse_difference_vs_train_mean")
    raw_module = metric_row(module, "raw_cosine_difference_vs_train_mean")
    residual_module = metric_row(module, "perturbation_specific_residual_cosine")
    point_rmse_mean = metric_row(point, "rmse_difference_vs_train_mean")
    return (
        "# Leave-GFOD2-out sensitivity analysis v0.1\n\n"
        "## Result\n\n"
        "Removing GFOD2 from the 20-realization v2.3 benchmark leaves the central model-comparison "
        "conclusion unchanged. The corrected two-stage 95% intervals remain below zero for RMSE "
        "versus both zero and train-mean comparators. The response-module block analysis also retains "
        "both RMSE conclusions, while the two cosine intervals remain mixed, as in the full analysis.\n\n"
        f"- RMSE versus zero, two-stage 95% CI: [{rmse_zero['two_stage_mean_ci95_low']:.6f}, "
        f"{rmse_zero['two_stage_mean_ci95_high']:.6f}]\n"
        f"- RMSE versus train mean, two-stage 95% CI: [{rmse_mean['two_stage_mean_ci95_low']:.6f}, "
        f"{rmse_mean['two_stage_mean_ci95_high']:.6f}]\n"
        f"- Raw-cosine module-block classification: {raw_module['interval_classification']}\n"
        f"- Residual-cosine module-block classification: {residual_module['interval_classification']}\n"
        f"- Mean RMSE difference versus train mean after exclusion: "
        f"{point_rmse_mean['mean_across_realizations_and_targets']:.9f} "
        f"(change versus all targets {point_rmse_mean['delta_vs_all_targets']:+.9f})\n\n"
        "## Prespecified structure\n\n"
        f"GFOD2 belonged to response-profile module {target_module}. That module contains 39 targets "
        "after exclusion; the other 53 modules retain 40 targets. The original module assignment is "
        "preserved, and modules are not re-clustered or rebalanced.\n\n"
        "## Candidate interpretation\n\n"
        "The current action queue contains 20 remaining active candidates after GFOD2 is omitted. "
        "Their action categories and action-then-gene display policy are unchanged. No replacement "
        "top gene and no new numeric rank are created.\n\n"
        "## Claim boundary\n\n"
        "This analysis shows that the resource-level benchmark and current action queue do not depend "
        "on GFOD2. It does not establish that GFOD2 is biologically unimportant. The author-reported "
        "cross-project assessment places GFOD2 on provisional hold pending auditable quantitative "
        "evidence import; it does not yet support formal deprioritization.\n\n"
        "## Reproduction\n\n"
        "Run `python3 scripts/build_gfod2_exclusion_sensitivity_v01.py`. The full-scope bootstrap is "
        "recomputed and must match the canonical v2.3 serialized interval values within an absolute "
        "tolerance of 1 × 10^-15 before the leave-out result is accepted. This tolerance covers only "
        "the final decimal-to-binary round trip; it is not a statistical tolerance. See "
        "`gfod2_exclusion_manifest.json` for seeds and SHA-256 provenance.\n"
    )


def main() -> None:
    args = parse_args()
    out_dir = args.out_dir.resolve()
    if out_dir.exists():
        if not args.overwrite:
            raise FileExistsError(f"Refusing to overwrite existing output directory: {out_dir}")
        shutil.rmtree(out_dir)
    out_dir.mkdir(parents=True, exist_ok=False)

    genes, matrices, score_paths = load_score_matrices()
    keep_mask = np.asarray([gene != TARGET_GENE for gene in genes], dtype=bool)
    point, target_effects = summarize_point_estimates(matrices, keep_mask)
    two_stage, two_stage_checks = summarize_two_stage(matrices, keep_mask)
    module, module_checks, target_module = summarize_module_bootstrap(genes, matrices, keep_mask)
    action_summary, candidate_checks = candidate_action_summary()
    claim_gates = build_claim_gates(
        two_stage,
        module,
        two_stage_checks + module_checks,
        candidate_checks,
    )
    if not claim_gates["status"].eq("PASS").all():
        failures = claim_gates.loc[
            claim_gates["status"] != "PASS", ["gate", "detail"]
        ].to_dict(orient="records")
        raise RuntimeError(f"Sensitivity gates failed: {json.dumps(failures, indent=2)}")

    write_tsv(out_dir / "gfod2_exclusion_point_estimates.tsv", point)
    write_tsv(out_dir / "gfod2_target_effects_across_realizations.tsv", target_effects)
    write_tsv(out_dir / "gfod2_exclusion_corrected_two_stage_uncertainty.tsv", two_stage)
    write_tsv(out_dir / "gfod2_exclusion_module_block_uncertainty.tsv", module)
    write_tsv(out_dir / "gfod2_exclusion_candidate_action_summary.tsv", action_summary)
    write_tsv(out_dir / "gfod2_exclusion_claim_gate.tsv", claim_gates)
    (out_dir / "README.md").write_text(
        build_readme(point, two_stage, module, target_module), encoding="utf-8"
    )

    input_paths = score_paths + [
        CANONICAL_TWO_STAGE,
        MODULES,
        CANONICAL_MODULE,
        CANDIDATE_STATUS,
    ]
    output_paths = sorted(
        path for path in out_dir.iterdir() if path.name != "gfod2_exclusion_manifest.json"
    )
    manifest = {
        "status": "COMPLETE_REPRODUCIBLE_SENSITIVITY_ANALYSIS",
        "analysis_version": "gfod2_exclusion_sensitivity_v01",
        "target_excluded": TARGET_GENE,
        "claim_boundary": (
            "Resource-benchmark and action-queue robustness only; no biological-null or therapeutic claim."
        ),
        "n_realizations": len(SEEDS),
        "n_targets_full": len(genes),
        "n_targets_after_exclusion": int(keep_mask.sum()),
        "seeds": {
            "realizations": SEEDS,
            "canonical_two_stage_bootstrap": CANONICAL_TWO_STAGE_SEED,
            "canonical_module_bootstrap": CANONICAL_MODULE_SEED,
            "leave_out_two_stage_bootstrap": EXCLUSION_TWO_STAGE_SEED,
            "leave_out_module_bootstrap": EXCLUSION_MODULE_SEED,
        },
        "bootstrap_replicates": REPLICATES,
        "canonical_serialized_numeric_tolerance": SERIALIZED_NUMERIC_TOLERANCE,
        "module_policy": (
            "Retain the predeclared 54-module assignment; remove GFOD2 only; do not re-cluster or rebalance."
        ),
        "determinism_class": "deterministic_given_inputs_code_environment_and_fixed_seeds",
        "software": {
            "python": platform.python_version(),
            "numpy": np.__version__,
            "pandas": pd.__version__,
            "platform": platform.platform(),
        },
        "code": {
            "path": str(Path(__file__).resolve().relative_to(ROOT)),
            "sha256": sha256(Path(__file__).resolve()),
        },
        "inputs": [
            {"path": str(path.relative_to(ROOT)), "sha256": sha256(path)} for path in input_paths
        ],
        "outputs": [
            {"path": path.name, "sha256": sha256(path), "bytes": path.stat().st_size}
            for path in output_paths
        ],
        "all_gates_pass": True,
        "n_gates": int(len(claim_gates)),
    }
    (out_dir / "gfod2_exclusion_manifest.json").write_text(
        json.dumps(manifest, indent=2) + "\n", encoding="utf-8"
    )

    print(claim_gates.to_string(index=False))
    print(f"\nWrote {len(output_paths) + 1} files to {out_dir}")


if __name__ == "__main__":
    try:
        main()
    except Exception as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        raise
