#!/usr/bin/env python3
"""
Pre-registered analysis: screening decisions vs publication year in a
systematic-literature-review corpus on business data sharing through data
marketplaces.

Source dataset (pinned): DOI 10.4121/14673813.v2, licence CC BY 4.0.
Single file: Research_Data_Business_Data_Sharing_vJTAER.xlsx
  size  = 814076 bytes
  md5   = 27495339d87f52d4f3036a9927c94b7e

Data acquisition strategy (in order):
  1. Attempt canonical download from figshare and md5-verify.
  2. Fall back to an md5-gated local copy.
Either way the md5 is the sole authority: any mismatch is a hard failure.

Pre-registered question
------------------------
Within this screened corpus, is a record's publication year associated with
its screening decision (Include vs Exclude), and how are exclusion reasons
distributed?

Pre-registered tests (all two-sided, alpha = 0.05)
  H1  Mann-Whitney U on publication Year, Include vs Exclude.
      Effect size: rank-biserial correlation r = 2*AUC - 1, AUC = P(Include > Exclude).
  H2  Cochran-Armitage trend test for inclusion proportion across ordered years.
  H3  Chi-square goodness-of-fit that the 5 exclusion-reason groups are
      NOT equiprobable. Effect size: Cohen's w.
  Family-wise Holm correction across the 3 primary tests.

Robustness / sensitivity
  R1  Re-run H1 and H2 on Scopus-only records (drop 9 hand-seeded core Includes).
  R2  Re-run trend test on 5-year publication-year bins.
  Shapiro-Wilk normality reported to justify the nonparametric choice.

All seeds are fixed. Re-running this script reproduces every number, figure,
and table.
"""
import os
import sys
import json
import random
import hashlib
import subprocess
from datetime import datetime, timezone

import numpy as np
import pandas as pd
from scipy import stats
from statsmodels.stats.multitest import multipletests
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt

# --------------------------------------------------------------------------
# Reproducibility: fix all seeds.
# --------------------------------------------------------------------------
SEED = 42
random.seed(SEED)
np.random.seed(SEED)

# --------------------------------------------------------------------------
# Constants.
# --------------------------------------------------------------------------
FIGSHARE_URL = "https://ndownloader.figshare.com/files/31550600"
EXPECTED_MD5 = "27495339d87f52d4f3036a9927c94b7e"
EXPECTED_SIZE = 814076
LOCAL_FALLBACK = (
    "/Users/markhahnel/datasetpapers-handfeed/data-marketplace/"
    "Research_Data_Business_Data_Sharing_vJTAER.xlsx"
)
SHEET = "1. Identified articles"

HERE = os.path.dirname(os.path.abspath(__file__))
FIG_DIR = os.path.join(HERE, "figures")
TBL_DIR = os.path.join(HERE, "tables")
DATA_CACHE = os.path.join(HERE, "data_source.xlsx")
for _d in (FIG_DIR, TBL_DIR):
    os.makedirs(_d, exist_ok=True)

# Colourblind-safe palette (Wong 2011). Include = blue, Exclude = orange.
CB = {
    "include": "#0072B2",
    "exclude": "#E69F00",
    "accent": "#009E73",
    "neutral": "#555555",
    "bars": ["#0072B2", "#E69F00", "#009E73", "#CC79A7", "#56B4E9"],
}


def md5_of(path):
    h = hashlib.md5()
    with open(path, "rb") as fh:
        for chunk in iter(lambda: fh.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()


def acquire_data():
    """Return a path to an md5-verified copy of the source workbook.

    Tries the canonical figshare URL first; falls back to the pinned local
    copy. In all cases the md5 must equal EXPECTED_MD5 or we hard-fail.
    """
    # 1. Canonical download attempt.
    try:
        import urllib.request

        print(f"[data] attempting canonical download: {FIGSHARE_URL}")
        urllib.request.urlretrieve(FIGSHARE_URL, DATA_CACHE)
        got = md5_of(DATA_CACHE)
        if got == EXPECTED_MD5:
            print(f"[data] figshare download OK, md5 verified ({got})")
            return DATA_CACHE
        raise ValueError(
            f"figshare md5 mismatch: got {got}, expected {EXPECTED_MD5}"
        )
    except Exception as exc:  # network blocked, mismatch, etc.
        print(f"[data] canonical download unavailable ({exc}); using local fallback")

    # 2. Local md5-gated fallback.
    if not os.path.exists(LOCAL_FALLBACK):
        sys.exit(f"[fatal] local fallback not found: {LOCAL_FALLBACK}")
    got = md5_of(LOCAL_FALLBACK)
    if got != EXPECTED_MD5:
        sys.exit(
            f"[fatal] local md5 mismatch: got {got}, expected {EXPECTED_MD5}"
        )
    print(f"[data] local fallback md5 verified ({got})")
    return LOCAL_FALLBACK


def cochran_armitage(successes, totals, scores):
    """Cochran-Armitage test for trend in proportions.

    successes: array of 'Include' counts per ordered group.
    totals:    array of total counts per group.
    scores:    ordered numeric scores (e.g. year or bin midpoint).
    Returns (Z, two-sided p).
    """
    scores = np.asarray(scores, float)
    ni = np.asarray(totals, float)
    xi = np.asarray(successes, float)
    N = ni.sum()
    R = xi.sum()
    sbar = np.sum(ni * scores) / N
    T = np.sum(xi * (scores - sbar))
    pbar = R / N
    var = pbar * (1 - pbar) * np.sum(ni * (scores - sbar) ** 2)
    Z = T / np.sqrt(var)
    p = 2 * stats.norm.sf(abs(Z))
    return float(Z), float(p)


# Exclusion-group labels (from the coding sheet).
EXCL_LABELS = {
    1: "Not about data marketplace/market",
    2: "Marketplace peripheral; emphasis elsewhere",
    3: "Workshop/proceeding description, not a research paper",
    4: "No abstract available",
    5: "Not in English",
}


def load_screening(path):
    df = pd.read_excel(path, sheet_name=SHEET)
    df = df.copy()
    df["decision"] = df["Included or excluded?"].astype(str).str.strip()
    df["year"] = df["Year"].astype(int)
    # Guard: decision and year must be complete.
    assert df["decision"].isin(["Include", "Exclude"]).all(), "unexpected decision labels"
    assert df["year"].notna().all(), "missing years"
    return df


def run_analysis(df):
    R = {}
    R["_meta"] = {
        "generated_utc": datetime.now(timezone.utc).isoformat(),
        "seed": SEED,
        "source_md5": EXPECTED_MD5,
        "sheet": SHEET,
        "n_records": int(len(df)),
        "n_include": int((df["decision"] == "Include").sum()),
        "n_exclude": int((df["decision"] == "Exclude").sum()),
        "year_min": int(df["year"].min()),
        "year_max": int(df["year"].max()),
    }

    inc = df.loc[df["decision"] == "Include", "year"].values
    exc = df.loc[df["decision"] == "Exclude", "year"].values

    # Normality (justification for nonparametric primary tests).
    sw_i = stats.shapiro(inc)
    sw_e = stats.shapiro(exc)
    R["normality_shapiro"] = {
        "test": "Shapiro-Wilk",
        "include": {"W": float(sw_i.statistic), "p": float(sw_i.pvalue), "n": int(len(inc))},
        "exclude": {"W": float(sw_e.statistic), "p": float(sw_e.pvalue), "n": int(len(exc))},
        "conclusion": "both groups reject normality; nonparametric tests used",
    }

    # H1 Mann-Whitney U.
    U, p_mwu = stats.mannwhitneyu(inc, exc, alternative="two-sided")
    n1, n2 = len(inc), len(exc)
    auc = U / (n1 * n2)  # P(Include > Exclude)
    rb = 2 * auc - 1  # rank-biserial correlation
    R["H1_mannwhitney_year_by_decision"] = {
        "test": "Mann-Whitney U (two-sided)",
        "statistic_U": float(U),
        "p": float(p_mwu),
        "effect_size_rank_biserial": float(rb),
        "auc_include_gt_exclude": float(auc),
        "median_include": float(np.median(inc)),
        "median_exclude": float(np.median(exc)),
        "iqr_include": [float(np.percentile(inc, 25)), float(np.percentile(inc, 75))],
        "iqr_exclude": [float(np.percentile(exc, 25)), float(np.percentile(exc, 75))],
        "n_include": int(n1),
        "n_exclude": int(n2),
    }

    # H2 Cochran-Armitage trend across years.
    yr = np.sort(df["year"].unique())
    inc_c = df.groupby("year")["decision"].apply(lambda s: (s == "Include").sum()).reindex(yr).values
    tot_c = df.groupby("year")["decision"].size().reindex(yr).values
    Z_ca, p_ca = cochran_armitage(inc_c, tot_c, yr)
    rho, p_rho = stats.spearmanr(df["year"], (df["decision"] == "Include").astype(int))
    R["H2_cochran_armitage_trend"] = {
        "test": "Cochran-Armitage trend (two-sided)",
        "statistic_Z": float(Z_ca),
        "p": float(p_ca),
        "spearman_rho_year_include": float(rho),
        "spearman_p": float(p_rho),
        "direction": "inclusion proportion increases with year" if Z_ca > 0 else "decreases",
        "n": int(len(df)),
        "n_years": int(len(yr)),
    }

    # H3 chi-square goodness-of-fit on 5 exclusion groups.
    grp = df.loc[df["decision"] == "Exclude", "Reason of exclusion (by group)"].astype(int)
    obs = grp.value_counts().reindex([1, 2, 3, 4, 5]).fillna(0).astype(int).values
    assert obs.sum() == n2, "exclusion group counts do not reconcile with n_exclude"
    exp = np.full(5, obs.sum() / 5)
    chi2, p_chi = stats.chisquare(obs, exp)
    w = float(np.sqrt(chi2 / obs.sum()))
    R["H3_chisquare_exclusion_groups"] = {
        "test": "Chi-square goodness-of-fit (equiprobable)",
        "statistic_chi2": float(chi2),
        "p": float(p_chi),
        "dof": 4,
        "effect_size_cohens_w": w,
        "observed": {str(k): int(v) for k, v in zip([1, 2, 3, 4, 5], obs)},
        "expected_each": float(exp[0]),
        "labels": EXCL_LABELS,
        "n": int(obs.sum()),
    }

    # Holm correction across the 3 primary tests.
    keys = ["H1_mannwhitney_year_by_decision", "H2_cochran_armitage_trend", "H3_chisquare_exclusion_groups"]
    pvals = [R[k]["p"] for k in keys]
    rej, p_holm, _, _ = multipletests(pvals, method="holm")
    R["holm_correction"] = {
        "method": "Holm-Bonferroni",
        "family": keys,
        "raw_p": [float(x) for x in pvals],
        "corrected_p": [float(x) for x in p_holm],
        "reject_at_0.05": [bool(x) for x in rej],
    }
    for k, pc, rj in zip(keys, p_holm, rej):
        R[k]["p_holm"] = float(pc)
        R[k]["significant_holm"] = bool(rj)

    # R1 robustness: Scopus-only.
    ds = df[df["Source"] == "Scopus"]
    inc_s = ds.loc[ds["decision"] == "Include", "year"].values
    exc_s = ds.loc[ds["decision"] == "Exclude", "year"].values
    U_s, p_mwu_s = stats.mannwhitneyu(inc_s, exc_s, alternative="two-sided")
    auc_s = U_s / (len(inc_s) * len(exc_s))
    yr_s = np.sort(ds["year"].unique())
    inc_cs = ds.groupby("year")["decision"].apply(lambda s: (s == "Include").sum()).reindex(yr_s).values
    tot_cs = ds.groupby("year")["decision"].size().reindex(yr_s).values
    Z_cs, p_cs = cochran_armitage(inc_cs, tot_cs, yr_s)
    R["R1_robustness_scopus_only"] = {
        "description": "Primary tests re-run on Scopus-only records (9 hand-seeded core Includes dropped)",
        "n": int(len(ds)),
        "n_include": int(len(inc_s)),
        "n_exclude": int(len(exc_s)),
        "mannwhitney": {
            "statistic_U": float(U_s),
            "p": float(p_mwu_s),
            "effect_size_rank_biserial": float(2 * auc_s - 1),
            "median_include": float(np.median(inc_s)),
            "median_exclude": float(np.median(exc_s)),
        },
        "cochran_armitage": {"statistic_Z": float(Z_cs), "p": float(p_cs)},
    }

    # R2 sensitivity: 5-year bins.
    bins = [1979, 1984, 1989, 1994, 1999, 2004, 2009, 2014, 2020]
    dbin = df.copy()
    dbin["ybin"] = pd.cut(dbin["year"], bins=bins)
    agg = dbin.groupby("ybin", observed=True).agg(
        n=("decision", "size"),
        inc=("decision", lambda s: (s == "Include").sum()),
    )
    agg["rate"] = agg["inc"] / agg["n"]
    mids = [(iv.left + iv.right) / 2 for iv in agg.index]
    Z_b, p_b = cochran_armitage(agg["inc"].values, agg["n"].values, mids)
    R["R2_sensitivity_5yr_bins"] = {
        "description": "Trend test on 5-year publication-year bins",
        "bins": [str(iv) for iv in agg.index],
        "n_per_bin": [int(x) for x in agg["n"].values],
        "include_per_bin": [int(x) for x in agg["inc"].values],
        "inclusion_rate_per_bin": [float(x) for x in agg["rate"].values],
        "cochran_armitage": {"statistic_Z": float(Z_b), "p": float(p_b)},
    }

    return R


def _style():
    plt.rcParams.update({
        "figure.dpi": 150,
        "savefig.dpi": 200,
        "font.size": 11,
        "axes.titlesize": 12,
        "axes.labelsize": 11,
        "xtick.labelsize": 10,
        "ytick.labelsize": 10,
        "legend.fontsize": 10,
        "axes.spines.top": False,
        "axes.spines.right": False,
        "figure.autolayout": False,
    })


def make_figures(df, R):
    _style()
    paths = {}

    # fig-1: year distribution by decision (supports H1 Mann-Whitney).
    inc = df.loc[df["decision"] == "Include", "year"].values
    exc = df.loc[df["decision"] == "Exclude", "year"].values
    fig, ax = plt.subplots(figsize=(7.0, 4.6))
    data = [inc, exc]
    colors = [CB["include"], CB["exclude"]]
    labels = [f"Include (n={len(inc)})", f"Exclude (n={len(exc)})"]
    positions = [1, 2]
    bp = ax.boxplot(data, positions=positions, widths=0.5, patch_artist=True,
                    showfliers=False, medianprops=dict(color="black", linewidth=1.6),
                    whiskerprops=dict(color=CB["neutral"]), capprops=dict(color=CB["neutral"]),
                    boxprops=dict(edgecolor=CB["neutral"]))
    for patch, c in zip(bp["boxes"], colors):
        patch.set_facecolor(c); patch.set_alpha(0.35)
    rng = np.random.default_rng(SEED)
    for pos, arr, c in zip(positions, data, colors):
        jit = rng.uniform(-0.16, 0.16, size=len(arr))
        ax.scatter(pos + jit, arr, s=14, color=c, alpha=0.55, edgecolor="none", zorder=3)
    ax.set_xticks(positions); ax.set_xticklabels(labels)
    ax.set_ylabel("Publication year")
    p = R["H1_mannwhitney_year_by_decision"]
    ax.set_title("Included records are more recent than excluded records")
    ax.annotate(f"Mann-Whitney U p = {p['p']:.1e}\nrank-biserial r = {p['effect_size_rank_biserial']:.2f}\n"
                f"median {int(p['median_include'])} vs {int(p['median_exclude'])}",
                xy=(0.02, 0.03), xycoords="axes fraction", fontsize=9, va="bottom",
                bbox=dict(boxstyle="round,pad=0.3", fc="white", ec=CB["neutral"], alpha=0.8))
    ax.margins(y=0.05)
    fig.tight_layout()
    f1 = os.path.join(FIG_DIR, "fig-1-year-by-decision.png")
    fig.savefig(f1); plt.close(fig); paths["fig-1"] = f1

    # fig-2: inclusion rate by year with n (supports H2 trend).
    yr = np.sort(df["year"].unique())
    inc_c = df.groupby("year")["decision"].apply(lambda s: (s == "Include").sum()).reindex(yr).values
    tot_c = df.groupby("year")["decision"].size().reindex(yr).values
    rate = inc_c / tot_c
    fig, ax = plt.subplots(figsize=(8.2, 4.6))
    ax.plot(yr, rate, "-o", color=CB["include"], markersize=4, linewidth=1.4, label="Inclusion rate")
    # size markers by n
    ax.scatter(yr, rate, s=np.clip(tot_c * 4, 10, 260), color=CB["include"], alpha=0.25, zorder=2)
    # linear reference of the trend (visual only; test is Cochran-Armitage)
    b, a = np.polyfit(yr, rate, 1)
    ax.plot(yr, a + b * yr, "--", color=CB["accent"], linewidth=1.4, label="Linear guide")
    ax.set_xlabel("Publication year")
    ax.set_ylabel("Proportion of records included")
    ax.set_ylim(-0.05, 1.05)
    h2 = R["H2_cochran_armitage_trend"]
    ax.set_title("Inclusion rate rises across the screened period")
    ax.annotate(f"Cochran-Armitage Z = {h2['statistic_Z']:.2f}, p = {h2['p']:.1e}\n"
                f"(marker area \u221d records that year)",
                xy=(0.02, 0.92), xycoords="axes fraction", fontsize=9, va="top",
                bbox=dict(boxstyle="round,pad=0.3", fc="white", ec=CB["neutral"], alpha=0.8))
    ax.legend(frameon=False, loc="lower right")
    fig.tight_layout()
    f2 = os.path.join(FIG_DIR, "fig-2-inclusion-rate-by-year.png")
    fig.savefig(f2); plt.close(fig); paths["fig-2"] = f2

    # fig-3: exclusion-reason composition (supports H3 chi-square).
    h3 = R["H3_chisquare_exclusion_groups"]
    groups = [1, 2, 3, 4, 5]
    counts = [h3["observed"][str(g)] for g in groups]
    labs = [f"{g}. {EXCL_LABELS[g]}" for g in groups]
    order = np.argsort(counts)  # ascending for horizontal bar
    counts_o = [counts[i] for i in order]
    labs_o = [labs[i] for i in order]
    fig, ax = plt.subplots(figsize=(8.6, 4.4))
    ypos = np.arange(len(groups))
    ax.barh(ypos, counts_o, color=CB["exclude"], alpha=0.85, edgecolor=CB["neutral"])
    exp_each = h3["expected_each"]
    ax.axvline(exp_each, color=CB["neutral"], linestyle="--", linewidth=1.2)
    ax.annotate(f"equiprobable = {exp_each:.0f}", xy=(exp_each, len(groups) - 0.4),
                xytext=(exp_each + 4, len(groups) - 0.5), fontsize=9, color=CB["neutral"])
    for y, c in zip(ypos, counts_o):
        ax.text(c + 2, y, str(c), va="center", fontsize=10)
    ax.set_yticks(ypos); ax.set_yticklabels(labs_o)
    ax.set_xlabel("Number of excluded records")
    ax.set_title("Most exclusions are off-topic records")
    ax.annotate(f"chi-square = {h3['statistic_chi2']:.0f}, p = {h3['p']:.1e}\n"
                f"Cohen's w = {h3['effect_size_cohens_w']:.2f}, n = {h3['n']}",
                xy=(0.55, 0.10), xycoords="axes fraction", fontsize=9, va="bottom",
                bbox=dict(boxstyle="round,pad=0.3", fc="white", ec=CB["neutral"], alpha=0.8))
    ax.set_xlim(0, max(counts) * 1.15)
    fig.tight_layout()
    f3 = os.path.join(FIG_DIR, "fig-3-exclusion-reason-composition.png")
    fig.savefig(f3); plt.close(fig); paths["fig-3"] = f3

    # fig-4: robustness, full vs Scopus-only inclusion rate by year.
    ds = df[df["Source"] == "Scopus"]
    yr_s = np.sort(ds["year"].unique())
    inc_cs = ds.groupby("year")["decision"].apply(lambda s: (s == "Include").sum()).reindex(yr_s).values
    tot_cs = ds.groupby("year")["decision"].size().reindex(yr_s).values
    rate_s = inc_cs / tot_cs
    fig, ax = plt.subplots(figsize=(8.2, 4.6))
    ax.plot(yr, rate, "-o", color=CB["include"], markersize=4, linewidth=1.4,
            label="Full corpus")
    ax.plot(yr_s, rate_s, "-s", color=CB["accent"], markersize=4, linewidth=1.4,
            label="Scopus-only")
    ax.set_xlabel("Publication year")
    ax.set_ylabel("Proportion of records included")
    ax.set_ylim(-0.05, 1.05)
    r1 = R["R1_robustness_scopus_only"]
    ax.set_title("Trend is unchanged when hand-seeded records are dropped")
    ax.annotate(f"Scopus-only: Cochran-Armitage Z = {r1['cochran_armitage']['statistic_Z']:.2f}, "
                f"p = {r1['cochran_armitage']['p']:.1e}\n"
                f"Mann-Whitney p = {r1['mannwhitney']['p']:.1e}",
                xy=(0.02, 0.92), xycoords="axes fraction", fontsize=9, va="top",
                bbox=dict(boxstyle="round,pad=0.3", fc="white", ec=CB["neutral"], alpha=0.8))
    ax.legend(frameon=False, loc="lower right")
    fig.tight_layout()
    f4 = os.path.join(FIG_DIR, "fig-4-robustness-scopus-only.png")
    fig.savefig(f4); plt.close(fig); paths["fig-4"] = f4

    return paths


def make_tables(df, R):
    paths = {}

    # tbl-1: decision x year summary.
    p1 = R["H1_mannwhitney_year_by_decision"]
    t1 = pd.DataFrame([
        {"decision": "Include", "n": p1["n_include"], "median_year": p1["median_include"],
         "q1_year": p1["iqr_include"][0], "q3_year": p1["iqr_include"][1]},
        {"decision": "Exclude", "n": p1["n_exclude"], "median_year": p1["median_exclude"],
         "q1_year": p1["iqr_exclude"][0], "q3_year": p1["iqr_exclude"][1]},
    ])
    t1["mannwhitney_U"] = p1["statistic_U"]
    t1["p_value"] = p1["p"]
    t1["p_holm"] = p1["p_holm"]
    t1["rank_biserial_r"] = p1["effect_size_rank_biserial"]
    f1 = os.path.join(TBL_DIR, "tbl-1-decision-year-summary.csv")
    t1.to_csv(f1, index=False); paths["tbl-1"] = f1

    # tbl-2: inclusion rate by year.
    yr = np.sort(df["year"].unique())
    inc_c = df.groupby("year")["decision"].apply(lambda s: (s == "Include").sum()).reindex(yr).values
    tot_c = df.groupby("year")["decision"].size().reindex(yr).values
    t2 = pd.DataFrame({"year": yr, "n_records": tot_c, "n_included": inc_c,
                       "inclusion_rate": inc_c / tot_c})
    f2 = os.path.join(TBL_DIR, "tbl-2-inclusion-rate-by-year.csv")
    t2.to_csv(f2, index=False); paths["tbl-2"] = f2

    # tbl-3: exclusion reason groups.
    h3 = R["H3_chisquare_exclusion_groups"]
    rows = []
    for g in [1, 2, 3, 4, 5]:
        c = h3["observed"][str(g)]
        rows.append({"group": g, "label": EXCL_LABELS[g], "n": c,
                     "pct_of_excluded": round(100 * c / h3["n"], 2)})
    t3 = pd.DataFrame(rows)
    t3["chi2"] = h3["statistic_chi2"]; t3["p_value"] = h3["p"]
    t3["p_holm"] = h3["p_holm"]; t3["cohens_w"] = h3["effect_size_cohens_w"]
    f3 = os.path.join(TBL_DIR, "tbl-3-exclusion-reason-groups.csv")
    t3.to_csv(f3, index=False); paths["tbl-3"] = f3

    # tbl-4: robustness Scopus-only + 5yr bins.
    r1 = R["R1_robustness_scopus_only"]; r2 = R["R2_sensitivity_5yr_bins"]
    rows = [
        {"analysis": "Full corpus MWU", "statistic": R["H1_mannwhitney_year_by_decision"]["statistic_U"],
         "p_value": R["H1_mannwhitney_year_by_decision"]["p"],
         "effect_size": R["H1_mannwhitney_year_by_decision"]["effect_size_rank_biserial"], "n": R["_meta"]["n_records"]},
        {"analysis": "Scopus-only MWU", "statistic": r1["mannwhitney"]["statistic_U"],
         "p_value": r1["mannwhitney"]["p"], "effect_size": r1["mannwhitney"]["effect_size_rank_biserial"], "n": r1["n"]},
        {"analysis": "Full corpus Cochran-Armitage", "statistic": R["H2_cochran_armitage_trend"]["statistic_Z"],
         "p_value": R["H2_cochran_armitage_trend"]["p"], "effect_size": R["H2_cochran_armitage_trend"]["spearman_rho_year_include"],
         "n": R["_meta"]["n_records"]},
        {"analysis": "Scopus-only Cochran-Armitage", "statistic": r1["cochran_armitage"]["statistic_Z"],
         "p_value": r1["cochran_armitage"]["p"], "effect_size": np.nan, "n": r1["n"]},
        {"analysis": "5-year-bin Cochran-Armitage", "statistic": r2["cochran_armitage"]["statistic_Z"],
         "p_value": r2["cochran_armitage"]["p"], "effect_size": np.nan, "n": R["_meta"]["n_records"]},
    ]
    t4 = pd.DataFrame(rows)
    f4 = os.path.join(TBL_DIR, "tbl-4-robustness-scopus-only.csv")
    t4.to_csv(f4, index=False); paths["tbl-4"] = f4

    return paths, {"tbl-1": t1, "tbl-2": t2, "tbl-3": t3, "tbl-4": t4}


def _fields_for(dfobj):
    tmap = {"int64": "integer", "float64": "number", "object": "string", "bool": "boolean"}
    return [{"name": c, "type": tmap.get(str(dfobj[c].dtype), "string")} for c in dfobj.columns]


def write_datapackage(tframes):
    descriptions = {
        "tbl-1": "Median publication year and Mann-Whitney U comparison of Include vs Exclude records.",
        "tbl-2": "Per-year record counts, included counts, and inclusion rate.",
        "tbl-3": "Distribution of the five exclusion-reason groups among excluded records.",
        "tbl-4": "Robustness and sensitivity re-analyses (Scopus-only, 5-year bins).",
    }
    files = {"tbl-1": "tbl-1-decision-year-summary.csv", "tbl-2": "tbl-2-inclusion-rate-by-year.csv",
             "tbl-3": "tbl-3-exclusion-reason-groups.csv", "tbl-4": "tbl-4-robustness-scopus-only.csv"}
    resources = []
    for key, fname in files.items():
        resources.append({
            "name": key,
            "path": fname,
            "profile": "tabular-data-resource",
            "format": "csv",
            "mediatype": "text/csv",
            "encoding": "utf-8",
            "description": descriptions[key],
            "schema": {"fields": _fields_for(tframes[key])},
        })
    dp = {
        "profile": "tabular-data-package",
        "name": "data-marketplace-slr-screening-analysis",
        "title": "Screening decisions vs publication year: derived tables",
        "licenses": [{"name": "CC-BY-4.0", "title": "Creative Commons Attribution 4.0"}],
        "sources": [{"title": "Business Data Sharing through Data Marketplaces (SLR coding table)",
                     "path": "https://doi.org/10.4121/14673813.v2"}],
        "resources": resources,
    }
    fp = os.path.join(TBL_DIR, "datapackage.json")
    with open(fp, "w") as fh:
        json.dump(dp, fh, indent=2)
    return fp


def write_environment():
    fp = os.path.join(HERE, "environment.txt")
    lines = [f"python {sys.version.split()[0]}", ""]
    try:
        freeze = subprocess.check_output([sys.executable, "-m", "pip", "freeze"],
                                         text=True, stderr=subprocess.DEVNULL)
        lines.append(freeze.strip())
    except Exception as exc:
        lines.append(f"[pip freeze unavailable: {exc}]")
    with open(fp, "w") as fh:
        fh.write("\n".join(lines) + "\n")
    return fp


def main():
    src = acquire_data()
    df = load_screening(src)
    R = run_analysis(df)

    fig_paths = make_figures(df, R)
    tbl_paths, tframes = make_tables(df, R)
    dp_path = write_datapackage(tframes)

    R["_meta"]["figures"] = {k: os.path.basename(v) for k, v in fig_paths.items()}
    R["_meta"]["tables"] = {k: os.path.basename(v) for k, v in tbl_paths.items()}
    R["_meta"]["datapackage"] = os.path.basename(dp_path)

    with open(os.path.join(HERE, "results.json"), "w") as fh:
        json.dump(R, fh, indent=2)

    write_environment()

    print("[done] wrote results.json, figures/, tables/, environment.txt")
    print(f"  n={R['_meta']['n_records']} include={R['_meta']['n_include']} exclude={R['_meta']['n_exclude']}")
    print(f"  H1 MWU p={R['H1_mannwhitney_year_by_decision']['p']:.3e} "
          f"holm={R['H1_mannwhitney_year_by_decision']['p_holm']:.3e}")
    print(f"  H2 CA  p={R['H2_cochran_armitage_trend']['p']:.3e} "
          f"holm={R['H2_cochran_armitage_trend']['p_holm']:.3e}")
    print(f"  H3 chi2 p={R['H3_chisquare_exclusion_groups']['p']:.3e} "
          f"holm={R['H3_chisquare_exclusion_groups']['p_holm']:.3e}")


if __name__ == "__main__":
    main()
