#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Preferred gender vs. target gender in facial-attractiveness ratings
===================================================================

A self-contained, pre-registered secondary analysis. Running this script from a
clean checkout downloads the two pinned source files, verifies their md5, sets
all random seeds, runs the pre-registered tests, and writes every figure, table
and results.json. Re-running reproduces every number.

--------------------------------------------------------------------------------
PRE-REGISTRATION (frozen before any test was run)
--------------------------------------------------------------------------------
DATA
    Face Research Lab London Set, DOI 10.6084/m9.figshare.5047666.v5, CC BY 4.0.
    Exactly two files are used:
      - london_faces_info.csv      (102 faces: face_id, face_age, face_gender,
                                     face_eth)
      - london_faces_ratings.csv   (2513 raters x [rater_sex, rater_sexpref,
                                     rater_age, X001..X173]); each X-column is one
                                     face; cells are attractiveness ratings on a
                                     1..7 scale; the matrix is complete (no
                                     missing ratings).

QUESTION (open, grounded in the real columns)
    Do raters rate faces of their self-reported PREFERRED gender as more
    attractive than faces of the non-preferred gender, and is any such
    difference consistent across rater sex and sexual orientation? Or is the
    dominant signal the TARGET face gender itself, independent of preference?

UNIT OF ANALYSIS
    One value per rater (guards against pseudoreplication: the 102 ratings from
    one rater are not independent). Each rater contributes a single within-rater
    contrast d_i, which also removes rater-level scale-use bias.

PREFERRED FACE GENDER
    male   if rater_sexpref == 'men'
    female if rater_sexpref == 'women'

PER-RATER CONTRAST
    d_i = agg_over_preferred_gender_faces(rating)
          - agg_over_nonpreferred_gender_faces(rating)
    agg = mean (primary); agg = median (sensitivity analysis).

TESTS (declared before running; two-sided throughout)
    Test 1 (primary, confirmatory)
        Raters with rater_sexpref in {men, women}. Wilcoxon signed-rank of d_i
        against 0. Report n, statistic, p, median d, 95% bootstrap CI of the
        median (10000 resamples), matched-pairs rank-biserial r, Cohen's dz,
        and % of raters with d_i > 0.
        SENSITIVITY (declared): repeat with agg = median.
    Test 2 (robustness, confirmatory)
        Split the preferred-gender raters into the fully-populated 2x2 of
        rater_sex x rater_sexpref: hetero-female (pref men), hetero-male (pref
        women), homo-male (pref men), homo-female (pref women). Same per-rater
        Wilcoxon within each. Holm-Bonferroni correction across these 4 tests
        (declared correction family). This separates 'preferred gender' from
        'own gender': if the effect tracks preference, all four subgroups are
        positive; if it tracks target gender, the sign flips with preferred
        gender.
    Test 3 (negative control, confirmatory-test-negative)
        Raters with rater_sexpref == 'either' (no preferred gender). Define
        d_i = mean(male faces) - mean(female faces) (fixed arbitrary direction).
        Wilcoxon signed-rank vs 0. EXPECTED under the preference hypothesis:
        negligible / non-significant, because these raters have no preferred
        gender. A large directional effect here instead indicates a target-gender
        effect independent of preference.

STATISTICAL DISCIPLINE
    Nonparametric tests (ordinal Likert ratings; per-rater d not assumed
    normal). Multiple-comparison correction within the declared family (Test 2).
    Effect sizes and n reported for every test. No causal claims. All seeds set
    and recorded.
--------------------------------------------------------------------------------
"""
import os
import sys
import io
import json
import hashlib
import platform
import subprocess
import urllib.request
from pathlib import Path

import numpy as np
import pandas as pd
from scipy import stats

# --------------------------------------------------------------------------- #
# 0. Reproducibility: seeds
# --------------------------------------------------------------------------- #
SEED = 20240517
N_BOOT = 10000
np.random.seed(SEED)
_RNG = np.random.default_rng(SEED)

HERE = Path(__file__).resolve().parent
FIG_DIR = HERE / "figures"
TAB_DIR = HERE / "tables"
FIG_DIR.mkdir(exist_ok=True)
TAB_DIR.mkdir(exist_ok=True)

# --------------------------------------------------------------------------- #
# 1. Pinned provenance
# --------------------------------------------------------------------------- #
SOURCES = {
    "info": {
        "filename": "london_faces_info.csv",
        "url": "https://ndownloader.figshare.com/files/27397184",
        "md5": "52e9d142812130f0b19af168260fb160",
        "local_env": "FACES_INFO",
    },
    "ratings": {
        "filename": "london_faces_ratings.csv",
        "url": "https://ndownloader.figshare.com/files/8542045",
        "md5": "fe3743a1bb6b2b414e5170b8723aec1b",
        "local_env": "FACES_RATINGS",
    },
}
DATASET_DOI = "10.6084/m9.figshare.5047666.v5"
DATASET_LICENCE = "CC BY 4.0"


def _md5(b: bytes) -> str:
    return hashlib.md5(b).hexdigest()


def get_source(key: str) -> tuple[bytes, dict]:
    """Return md5-verified bytes for one source file.

    Canonical path: download from the pinned figshare URL and verify md5.
    Offline fallback: read an md5-gated local copy named by the *_env variable.
    The md5 gate guarantees that whichever route is taken, the bytes are
    byte-identical to the pinned source; non-matching bytes are rejected.
    """
    s = SOURCES[key]
    expected = s["md5"]
    prov = {"filename": s["filename"], "url": s["url"], "expected_md5": expected}

    # 1) canonical: figshare download
    try:
        req = urllib.request.Request(s["url"], headers={"User-Agent": "python-urllib"})
        with urllib.request.urlopen(req, timeout=120) as r:
            b = r.read()
        obs = _md5(b)
        if obs == expected:
            prov.update(source="figshare_url", observed_md5=obs, md5_ok=True)
            return b, prov
        # downloaded but mismatched -> do not use
        prov.update(source="figshare_url", observed_md5=obs, md5_ok=False,
                    note="downloaded bytes failed md5; falling back")
    except Exception as e:  # network blocked / unavailable
        prov.update(source="figshare_url", observed_md5=None, md5_ok=False,
                    note=f"download failed: {type(e).__name__}")

    # 2) offline fallback: md5-gated local copy
    local = os.environ.get(s["local_env"])
    if local and Path(local).is_file():
        b = Path(local).read_bytes()
        obs = _md5(b)
        if obs == expected:
            # record only the basename, never the absolute host path
            prov.update(source="local_md5_gated", local_filename=Path(local).name,
                        observed_md5=obs, md5_ok=True)
            return b, prov
        raise SystemExit(
            f"[FATAL] local file {local} md5 {obs} != pinned {expected}; refusing to use."
        )

    raise SystemExit(
        f"[FATAL] could not obtain {s['filename']} from figshare and no md5-gated "
        f"local copy found (set ${s['local_env']} to a byte-identical file)."
    )


# --------------------------------------------------------------------------- #
# 2. Effect-size and bootstrap helpers
# --------------------------------------------------------------------------- #
def rank_biserial_signed(d: np.ndarray) -> float:
    """Matched-pairs rank-biserial correlation from the Wilcoxon signed-rank
    statistic: (R+ - R-) / (R+ + R-), range -1..1."""
    d = np.asarray(d, float)
    d = d[d != 0]
    if d.size == 0:
        return float("nan")
    r = stats.rankdata(np.abs(d))
    Rpos = r[d > 0].sum()
    Rneg = r[d < 0].sum()
    T = Rpos + Rneg
    return float((Rpos - Rneg) / T) if T > 0 else float("nan")


def cohen_dz(d: np.ndarray) -> float:
    d = np.asarray(d, float)
    sd = np.std(d, ddof=1)
    return float(np.mean(d) / sd) if sd > 0 else float("nan")


def boot_ci_median(d: np.ndarray, n_boot: int = N_BOOT, alpha: float = 0.05,
                   seed: int = SEED) -> tuple[float, float]:
    d = np.asarray(d, float)
    rng = np.random.default_rng(seed)
    idx = rng.integers(0, d.size, size=(n_boot, d.size))
    meds = np.median(d[idx], axis=1)
    return float(np.quantile(meds, alpha / 2)), float(np.quantile(meds, 1 - alpha / 2))


def wilcoxon_report(d: np.ndarray, name: str, seed: int = SEED) -> dict:
    """Full pre-registered report for one Wilcoxon signed-rank test on d vs 0."""
    d = np.asarray(d, float)
    n = int(d.size)
    n_nonzero = int(np.sum(d != 0))
    w = stats.wilcoxon(d, alternative="two-sided", zero_method="wilcox")
    lo, hi = boot_ci_median(d, seed=seed)
    return {
        "test": "wilcoxon_signed_rank",
        "name": name,
        "n": n,
        "n_nonzero": n_nonzero,
        "statistic_W": float(w.statistic),
        "p_value": float(w.pvalue),
        "median_d": float(np.median(d)),
        "mean_d": float(np.mean(d)),
        "median_d_ci95": [round(lo, 6), round(hi, 6)],
        "rank_biserial_r": round(rank_biserial_signed(d), 6),
        "cohen_dz": round(cohen_dz(d), 6),
        "pct_d_gt_0": round(100.0 * float(np.mean(d > 0)), 4),
        "bootstrap_resamples": N_BOOT,
        "seed": seed,
    }


# --------------------------------------------------------------------------- #
# 3. Ingest + profile
# --------------------------------------------------------------------------- #
def main() -> None:
    results: dict = {
        "meta": {
            "analysis_seed": SEED,
            "bootstrap_resamples": N_BOOT,
            "dataset_doi": DATASET_DOI,
            "dataset_licence": DATASET_LICENCE,
            "python_version": platform.python_version(),
            "numpy_version": np.__version__,
            "scipy_version": stats.__name__ and __import__("scipy").__version__,
            "pandas_version": pd.__version__,
        },
        "provenance": {},
        "profile": {},
        "preregistration": {
            "unit_of_analysis": "one value per rater",
            "primary_test": "Wilcoxon signed-rank of per-rater contrast d_i vs 0",
            "contrast": "d_i = agg(preferred-gender faces) - agg(non-preferred-gender faces)",
            "aggregations": {"primary": "mean", "sensitivity": "median"},
            "correction_family": "Holm-Bonferroni across the 4 subgroups in Test 2",
            "all_tests_two_sided": True,
            "seed": SEED,
        },
    }

    info_b, info_prov = get_source("info")
    rat_b, rat_prov = get_source("ratings")
    results["provenance"]["info"] = info_prov
    results["provenance"]["ratings"] = rat_prov

    info = pd.read_csv(io.BytesIO(info_b))
    rat = pd.read_csv(io.BytesIO(rat_b))

    face_cols = [c for c in rat.columns if c.startswith("X")]
    colid = {c: int(c[1:]) for c in face_cols}
    id2gender = dict(zip(info.face_id, info.face_gender))
    male_cols = [c for c in face_cols if id2gender[colid[c]] == "male"]
    female_cols = [c for c in face_cols if id2gender[colid[c]] == "female"]

    rating_block = rat[face_cols].to_numpy(float)
    results["profile"] = {
        "info_shape": list(info.shape),
        "ratings_shape": list(rat.shape),
        "n_faces": int(info.shape[0]),
        "n_raters": int(rat.shape[0]),
        "n_face_columns": len(face_cols),
        "n_male_faces": len(male_cols),
        "n_female_faces": len(female_cols),
        "face_gender_counts": info.face_gender.value_counts().to_dict(),
        "face_eth_counts": info.face_eth.value_counts().to_dict(),
        "face_age": {
            "min": float(np.nanmin(info.face_age)),
            "max": float(np.nanmax(info.face_age)),
            "mean": round(float(np.nanmean(info.face_age)), 3),
            "n_missing": int(info.face_age.isna().sum()),
        },
        "rater_sex_counts": rat.rater_sex.value_counts(dropna=False).to_dict(),
        "rater_sexpref_counts": rat.rater_sexpref.value_counts(dropna=False).to_dict(),
        "rater_age": {
            "min": float(np.nanmin(rat.rater_age)),
            "max": float(np.nanmax(rat.rater_age)),
            "mean": round(float(np.nanmean(rat.rater_age)), 3),
            "n_missing": int(rat.rater_age.isna().sum()),
        },
        "rating_scale_min": float(np.nanmin(rating_block)),
        "rating_scale_max": float(np.nanmax(rating_block)),
        "n_missing_rating_cells": int(np.isnan(rating_block).sum()),
        "grand_mean_male_faces": round(float(np.nanmean(rat[male_cols].to_numpy(float))), 4),
        "grand_mean_female_faces": round(float(np.nanmean(rat[female_cols].to_numpy(float))), 4),
    }
    # keep string keys JSON-clean (NaN -> "NA")
    results["profile"]["rater_sex_counts"] = {
        ("NA" if pd.isna(k) else k): int(v)
        for k, v in results["profile"]["rater_sex_counts"].items()
    }
    results["profile"]["rater_sexpref_counts"] = {
        ("NA" if pd.isna(k) else k): int(v)
        for k, v in results["profile"]["rater_sexpref_counts"].items()
    }

    # ------------------------------------------------------------------- #
    # contrast builders
    # ------------------------------------------------------------------- #
    def contrast(df: pd.DataFrame, pref_gender: np.ndarray, agg: str = "mean") -> np.ndarray:
        mb = df[male_cols].to_numpy(float)
        fb = df[female_cols].to_numpy(float)
        if agg == "mean":
            m_male, m_female = np.nanmean(mb, axis=1), np.nanmean(fb, axis=1)
        elif agg == "median":
            m_male, m_female = np.nanmedian(mb, axis=1), np.nanmedian(fb, axis=1)
        else:
            raise ValueError(agg)
        pref_score = np.where(pref_gender == "male", m_male, m_female)
        nonpref_score = np.where(pref_gender == "male", m_female, m_male)
        return pref_score - nonpref_score

    # ------------------------------------------------------------------- #
    # TEST 1 (primary) + sensitivity
    # ------------------------------------------------------------------- #
    prim = rat[rat.rater_sexpref.isin(["men", "women"])].copy()
    prim_pref = np.where(prim.rater_sexpref == "men", "male", "female")
    d_mean = contrast(prim, prim_pref, "mean")
    d_median = contrast(prim, prim_pref, "median")

    r_primary = wilcoxon_report(d_mean, "primary_mean", seed=SEED)
    r_sensitivity = wilcoxon_report(d_median, "sensitivity_median", seed=SEED + 1)
    results["test1_primary"] = r_primary
    results["test1_sensitivity"] = r_sensitivity

    # ------------------------------------------------------------------- #
    # TEST 2 (subgroups) + Holm correction
    # ------------------------------------------------------------------- #
    subgroup_defs = [
        ("hetero_female", (rat.rater_sex == "female") & (rat.rater_sexpref == "men")),
        ("hetero_male",   (rat.rater_sex == "male")   & (rat.rater_sexpref == "women")),
        ("homo_male",     (rat.rater_sex == "male")   & (rat.rater_sexpref == "men")),
        ("homo_female",   (rat.rater_sex == "female") & (rat.rater_sexpref == "women")),
    ]
    sub_reports = []
    for i, (name, mask) in enumerate(subgroup_defs):
        sub = rat[mask].copy()
        pref = np.where(sub.rater_sexpref == "men", "male", "female")
        d = contrast(sub, pref, "mean")
        rep = wilcoxon_report(d, name, seed=SEED + 10 + i)
        rep["preferred_gender"] = "male" if (sub.rater_sexpref == "men").all() else "female"
        sub_reports.append(rep)

    # Holm-Bonferroni across the 4 raw p-values
    pvals = np.array([r["p_value"] for r in sub_reports])
    order = np.argsort(pvals)
    m = len(pvals)
    holm = np.empty(m)
    running = 0.0
    for rank, idx in enumerate(order):
        adj = (m - rank) * pvals[idx]
        running = max(running, adj)
        holm[idx] = min(running, 1.0)
    for r, hp in zip(sub_reports, holm):
        r["p_holm"] = float(hp)
        r["significant_holm_0.05"] = bool(hp < 0.05)
    results["test2_subgroups"] = sub_reports
    results["test2_correction"] = "holm-bonferroni"

    # ------------------------------------------------------------------- #
    # TEST 3 (negative control): 'either' raters, d = male - female
    # ------------------------------------------------------------------- #
    eith = rat[rat.rater_sexpref == "either"].copy()
    mb = np.nanmean(eith[male_cols].to_numpy(float), axis=1)
    fb = np.nanmean(eith[female_cols].to_numpy(float), axis=1)
    d_nc = mb - fb
    r_nc = wilcoxon_report(d_nc, "negative_control_either_male_minus_female", seed=SEED + 20)
    r_nc["direction"] = "male_faces_minus_female_faces"
    results["test3_negative_control"] = r_nc

    # ------------------------------------------------------------------- #
    # 4. Figures
    # ------------------------------------------------------------------- #
    make_figures(d_mean, sub_reports, subgroup_defs, rat, male_cols, female_cols,
                 d_nc, r_primary, r_nc)

    # ------------------------------------------------------------------- #
    # 5. Tables
    # ------------------------------------------------------------------- #
    write_tables(r_primary, r_sensitivity, sub_reports, r_nc)

    # ------------------------------------------------------------------- #
    # 6. results.json
    # ------------------------------------------------------------------- #
    with open(HERE / "results.json", "w") as f:
        json.dump(results, f, indent=2)

    # environment.txt
    write_environment()

    print("Analysis complete.")
    print(f"  primary  median d = {r_primary['median_d']:+.3f} "
          f"CI {r_primary['median_d_ci95']} p={r_primary['p_value']:.2e} "
          f"r_rb={r_primary['rank_biserial_r']:+.3f}")
    for r in sub_reports:
        print(f"  {r['name']:14s} median d = {r['median_d']:+.3f} "
              f"p_holm={r['p_holm']:.2e} r_rb={r['rank_biserial_r']:+.3f} n={r['n']}")
    print(f"  neg-ctrl median (M-F) = {r_nc['median_d']:+.3f} "
          f"CI {r_nc['median_d_ci95']} p={r_nc['p_value']:.2e} "
          f"r_rb={r_nc['rank_biserial_r']:+.3f}")


# --------------------------------------------------------------------------- #
# Figures
# --------------------------------------------------------------------------- #
def make_figures(d_mean, sub_reports, subgroup_defs, rat, male_cols, female_cols,
                 d_nc, r_primary, r_nc):
    import matplotlib
    matplotlib.use("Agg")
    import matplotlib.pyplot as plt

    # Okabe-Ito colourblind-safe palette
    OI = {
        "blue": "#0072B2", "orange": "#E69F00", "green": "#009E73",
        "vermillion": "#D55E00", "purple": "#CC79A7", "grey": "#555555",
    }
    plt.rcParams.update({
        "figure.dpi": 150, "savefig.dpi": 150,
        "font.size": 11, "axes.titlesize": 12, "axes.labelsize": 11,
        "xtick.labelsize": 10, "ytick.labelsize": 10, "legend.fontsize": 9,
        "axes.spines.top": False, "axes.spines.right": False,
        "figure.autolayout": False,
    })

    # ---- Figure 1: primary per-rater contrast distribution ----
    fig, ax = plt.subplots(figsize=(7.2, 4.4))
    lo, hi = r_primary["median_d_ci95"]
    med = r_primary["median_d"]
    bins = np.arange(-3.0, 3.0 + 1e-9, 0.2)
    ax.hist(d_mean, bins=bins, color=OI["blue"], alpha=0.75, edgecolor="white", linewidth=0.4)
    ax.axvline(0, color="black", lw=1.2, ls="--", label="no preference (d = 0)")
    ax.axvline(med, color=OI["vermillion"], lw=2.0,
               label=f"median = {med:+.2f}")
    ax.axvspan(lo, hi, color=OI["vermillion"], alpha=0.18, label="95% CI of median")
    ax.set_xlabel("Per-rater contrast  d = mean(preferred-gender) − mean(non-preferred-gender)")
    ax.set_ylabel("Number of raters")
    ax.set_title("Preferred-gender preference does not raise attractiveness ratings",
                 loc="left")
    pct = r_primary["pct_d_gt_0"]
    ax.text(0.015, 0.97,
            f"n = {r_primary['n']} raters\n{pct:.0f}% rate preferred gender higher (d > 0)\n"
            f"Wilcoxon p = {r_primary['p_value']:.1e}",
            transform=ax.transAxes, va="top", ha="left", fontsize=9,
            bbox=dict(boxstyle="round,pad=0.4", fc="white", ec=OI["grey"], alpha=0.9))
    ax.legend(loc="upper right", frameon=False)
    ax.margins(x=0.02)
    fig.tight_layout()
    fig.savefig(FIG_DIR / "fig-1-preferred-gender-contrast.png")
    plt.close(fig)

    # ---- Figure 2: subgroup forest plot ----
    fig, ax = plt.subplots(figsize=(8.4, 4.3))
    labels = {
        "hetero_female": "Heterosexual female\n(prefers men)",
        "hetero_male":   "Heterosexual male\n(prefers women)",
        "homo_male":     "Homosexual male\n(prefers men)",
        "homo_female":   "Homosexual female\n(prefers women)",
    }
    order_names = ["hetero_female", "homo_male", "hetero_male", "homo_female"]
    rep_by = {r["name"]: r for r in sub_reports}
    ys = np.arange(len(order_names))[::-1]
    for y, nm in zip(ys, order_names):
        r = rep_by[nm]
        lo, hi = r["median_d_ci95"]
        med = r["median_d"]
        col = OI["orange"] if "prefers men" in labels[nm] else OI["green"]
        ax.plot([lo, hi], [y, y], color=col, lw=2.5, solid_capstyle="round")
        ax.plot(med, y, "o", color=col, ms=9, zorder=3)
        # place annotation on the side away from 0 so it never crosses the axis
        if med < 0:
            ax.text(lo - 0.05, y, f"{med:+.2f}  (n={r['n']}, p={r['p_holm']:.0e})",
                    va="center", ha="right", fontsize=8.5)
        else:
            ax.text(hi + 0.05, y, f"{med:+.2f}  (n={r['n']}, p={r['p_holm']:.0e})",
                    va="center", ha="left", fontsize=8.5)
    ax.axvline(0, color="black", lw=1.2, ls="--")
    ax.set_yticks(ys)
    ax.set_yticklabels([labels[n] for n in order_names])
    ax.set_ylim(-0.6, len(order_names) - 0.4)
    ax.set_xlabel("Median per-rater contrast  d  (preferred − non-preferred gender)")
    ax.set_title("Contrast flips with the preferred gender, not with orientation",
                 loc="left")
    ax.set_xlim(-2.1, 2.4)
    # legend for colour meaning, placed in empty left band, clear of markers/text
    from matplotlib.lines import Line2D
    ax.legend(handles=[
        Line2D([0], [0], color=OI["orange"], lw=2.5, marker="o", label="prefers men"),
        Line2D([0], [0], color=OI["green"], lw=2.5, marker="o", label="prefers women"),
    ], loc="lower left", frameon=False, title="Rater's stated preference",
        fontsize=9, title_fontsize=9)
    fig.tight_layout()
    fig.savefig(FIG_DIR / "fig-2-subgroup-forest.png")
    plt.close(fig)

    # ---- Figure 3: negative control ----
    fig, ax = plt.subplots(figsize=(8.0, 4.4))
    bins = np.arange(-3.0, 2.0 + 1e-9, 0.2)
    ax.hist(d_nc, bins=bins, color=OI["purple"], alpha=0.8, edgecolor="white",
            linewidth=0.4,
            label=f"raters with no preferred gender (n={r_nc['n']})")
    ax.axvline(0, color="black", lw=1.2, ls="--", label="no gender difference (d = 0)")
    ax.axvline(r_nc["median_d"], color=OI["vermillion"], lw=2.0,
               label=f"median = {r_nc['median_d']:+.2f}")
    lo, hi = r_nc["median_d_ci95"]
    ax.axvspan(lo, hi, color=OI["vermillion"], alpha=0.18, label="95% CI of median")
    ax.set_xlabel("Per-rater contrast  d = mean(male faces) − mean(female faces)")
    ax.set_ylabel("Number of raters")
    ax.set_xlim(-3.0, 2.0)
    ax.set_title("Negative control: raters with no preferred gender still favour female faces",
                 loc="left")
    ax.text(0.015, 0.97,
            f"{100 - r_nc['pct_d_gt_0']:.0f}% rate female faces higher (d < 0)\n"
            f"Wilcoxon p = {r_nc['p_value']:.1e}\n"
            f"rank-biserial r = {r_nc['rank_biserial_r']:+.2f}",
            transform=ax.transAxes, va="top", ha="left", fontsize=9,
            bbox=dict(boxstyle="round,pad=0.4", fc="white", ec=OI["grey"], alpha=0.9))
    ax.legend(loc="upper right", frameon=False)
    fig.tight_layout()
    fig.savefig(FIG_DIR / "fig-3-negative-control.png")
    plt.close(fig)


# --------------------------------------------------------------------------- #
# Tables
# --------------------------------------------------------------------------- #
def _row(r: dict, group: str, agg: str) -> dict:
    return {
        "group": group,
        "aggregation": agg,
        "n": r["n"],
        "wilcoxon_W": round(r["statistic_W"], 3),
        "p_value": r["p_value"],
        "p_holm": r.get("p_holm", ""),
        "median_d": round(r["median_d"], 4),
        "median_d_ci95_lo": r["median_d_ci95"][0],
        "median_d_ci95_hi": r["median_d_ci95"][1],
        "rank_biserial_r": r["rank_biserial_r"],
        "cohen_dz": r["cohen_dz"],
        "pct_d_gt_0": r["pct_d_gt_0"],
    }


def write_tables(r_primary, r_sensitivity, sub_reports, r_nc):
    t1 = pd.DataFrame([
        _row(r_primary, "all preferred-gender raters", "mean (primary)"),
        _row(r_sensitivity, "all preferred-gender raters", "median (sensitivity)"),
    ])
    t1.to_csv(TAB_DIR / "tbl-1-primary-and-sensitivity.csv", index=False)

    label = {"hetero_female": "heterosexual female (prefers men)",
             "hetero_male": "heterosexual male (prefers women)",
             "homo_male": "homosexual male (prefers men)",
             "homo_female": "homosexual female (prefers women)"}
    t2 = pd.DataFrame([_row(r, label[r["name"]], "mean") for r in sub_reports])
    t2.to_csv(TAB_DIR / "tbl-2-subgroups.csv", index=False)

    t3 = pd.DataFrame([_row(r_nc, "raters with no preferred gender ('either')",
                            "mean (male − female)")])
    t3.to_csv(TAB_DIR / "tbl-3-negative-control.csv", index=False)

    write_datapackage()


def write_datapackage():
    fields_common = [
        {"name": "group", "type": "string"},
        {"name": "aggregation", "type": "string"},
        {"name": "n", "type": "integer"},
        {"name": "wilcoxon_W", "type": "number"},
        {"name": "p_value", "type": "number"},
        {"name": "p_holm", "type": "number"},
        {"name": "median_d", "type": "number"},
        {"name": "median_d_ci95_lo", "type": "number"},
        {"name": "median_d_ci95_hi", "type": "number"},
        {"name": "rank_biserial_r", "type": "number"},
        {"name": "cohen_dz", "type": "number"},
        {"name": "pct_d_gt_0", "type": "number"},
    ]
    dp = {
        "name": "preferred-vs-target-gender-attractiveness",
        "title": "Preferred gender vs. target gender in facial-attractiveness ratings",
        "licenses": [{"name": "CC-BY-4.0",
                      "title": "Creative Commons Attribution 4.0",
                      "path": "https://creativecommons.org/licenses/by/4.0/"}],
        "sources": [
            {"title": "Face Research Lab London Set (london_faces_info.csv)",
             "path": "https://ndownloader.figshare.com/files/27397184",
             "md5": SOURCES["info"]["md5"]},
            {"title": "Face Research Lab London Set (london_faces_ratings.csv)",
             "path": "https://ndownloader.figshare.com/files/8542045",
             "md5": SOURCES["ratings"]["md5"]},
        ],
        "resources": [
            {"name": "tbl-1-primary-and-sensitivity",
             "path": "tbl-1-primary-and-sensitivity.csv", "format": "csv",
             "mediatype": "text/csv", "schema": {"fields": fields_common}},
            {"name": "tbl-2-subgroups",
             "path": "tbl-2-subgroups.csv", "format": "csv",
             "mediatype": "text/csv", "schema": {"fields": fields_common}},
            {"name": "tbl-3-negative-control",
             "path": "tbl-3-negative-control.csv", "format": "csv",
             "mediatype": "text/csv", "schema": {"fields": fields_common}},
        ],
    }
    with open(TAB_DIR / "datapackage.json", "w") as f:
        json.dump(dp, f, indent=2)


# --------------------------------------------------------------------------- #
# environment.txt
# --------------------------------------------------------------------------- #
def write_environment():
    lines = [f"python {platform.python_version()} ({platform.platform()})", ""]
    try:
        freeze = subprocess.run([sys.executable, "-m", "pip", "freeze"],
                                capture_output=True, text=True, timeout=120)
        lines.append(freeze.stdout.strip())
    except Exception as e:
        lines.append(f"[pip freeze failed: {e}]")
    (HERE / "environment.txt").write_text("\n".join(lines) + "\n")


if __name__ == "__main__":
    main()
