"""V2M_F4 — Bilateral lift via confidence strips (refuge V2-M backbone).

V2-M counterpart to F4. Same 2x3 grid; image and fusion cells use refuge
V2-M runs. Clinical-only cells are backbone-independent so use the existing
phase2_v4/cd_solo_single and phase4_v4/cd_solo_bilateral runs.

Re-run anytime:
    python -m v4.figures.V2M_F4_bilateral
"""

from __future__ import annotations

import warnings

warnings.filterwarnings("ignore")

from pathlib import Path

import h5py
import matplotlib

matplotlib.use("Agg")
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import numpy as np
import pandas as pd
from sklearn.metrics import roc_auc_score

from v4.figures.util.loaders import REPO_ROOT, RESULTS_ROOT

OUT = Path(__file__).parent / "output" / "V2M_F4_bilateral.png"

# Palette (matches F3) ───────────────────────────────────────────────────────
C_NORMAL = "#78909C"
C_EARLY = "#29B6F6"
C_MODERATE = "#FFB300"
C_SEVERE = "#E53935"
C_UNKNOWN = "#BDBDBD"
SEV_COLORS = {
    "normal": C_NORMAL,
    "early": C_EARLY,
    "moderate": C_MODERATE,
    "severe": C_SEVERE,
    "unknown": C_UNKNOWN,
}
SEV_LABELS = {
    "normal": "Normal",
    "early": "Glaucoma — early (VF_MD > −6)",
    "moderate": "Glaucoma — moderate (−12 to −6)",
    "severe": "Glaucoma — severe (VF_MD < −12)",
    "unknown": "Glaucoma — VF_MD not recorded",
}
SEV_ORDER = ["normal", "unknown", "early", "moderate", "severe"]
SEV_ALPHA = {
    "normal": 0.40,
    "unknown": 0.35,
    "early": 0.55,
    "moderate": 0.70,
    "severe": 0.85,
}
SEV_SIZE = {"normal": 6, "unknown": 6, "early": 8, "moderate": 10, "severe": 12}

# Panel grid: [row][col] = (label, run_dir, eval_stage)
# All image / fusion cells use refuge V2-M backbone.
GRID = [
    [
        ("Single · Clinical", RESULTS_ROOT / "phase2_v4" / "cd_solo_single", "cd_fuse"),
        (
            "Single · Image",
            RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo_single_refuge_v2m",
            "img_fuse",
        ),
        (
            "Single · Fusion",
            RESULTS_ROOT / "refuge_v2m_baseline" / "ensemble_single_refuge_v2m",
            "nt",
        ),
    ],
    [
        (
            "Bilateral · Clinical",
            RESULTS_ROOT / "phase4_v4" / "cd_solo_bilateral",
            "hb",
        ),
        (
            "Bilateral · Image",
            RESULTS_ROOT / "refuge_v2m_baseline" / "img_solo",
            "hb",
        ),
        (
            "Bilateral · Fusion",
            RESULTS_ROOT / "efficientnet" / "refuge_efficientnetv2_m",
            "hb",
        ),
    ],
]

CLINICAL_DIR = REPO_ROOT / "Papila" / "ClinicalData"


# ── Same helpers as F3 ───────────────────────────────────────────────────────


def load_vfi() -> pd.DataFrame:
    od = pd.read_excel(CLINICAL_DIR / "patient_data_od.xlsx", header=1)
    os_ = pd.read_excel(CLINICAL_DIR / "patient_data_os.xlsx", header=1)

    def _clean(df):
        df = df.copy()
        if "Patient ID" not in df.columns and "ID" in df.columns:
            df.rename(columns={"ID": "Patient ID"}, inplace=True)
        df["Patient ID"] = (
            df["Patient ID"].astype(str).str.extract(r"(\d+)")[0].astype(int)
        )
        df["Diagnosis"] = pd.to_numeric(df["Diagnosis"], errors="coerce")
        df["VF_MD"] = pd.to_numeric(df["VF_MD"], errors="coerce")
        df = df[df["Diagnosis"].isin([0, 1])].copy()
        return df[["Patient ID", "Diagnosis", "VF_MD"]]

    both = pd.concat([_clean(od), _clean(os_)], ignore_index=True)
    diag = (
        both.groupby("Patient ID")["Diagnosis"]
        .agg(lambda x: x.mode().iloc[0])
        .reset_index()
    )
    vf = both.groupby("Patient ID")["VF_MD"].min().reset_index()
    out = diag.merge(vf, on="Patient ID").rename(
        columns={"Patient ID": "patient_id", "Diagnosis": "diagnosis", "VF_MD": "vf_md"}
    )

    def _sev(row):
        if int(row["diagnosis"]) == 0:
            return "normal"
        v = row["vf_md"]
        if pd.isna(v):
            return "unknown"
        if v > -6:
            return "early"
        if v > -12:
            return "moderate"
        return "severe"

    out["severity"] = out.apply(_sev, axis=1)
    return out


def collect_predictions(run_dir: Path, eval_stage: str) -> pd.DataFrame:
    if not run_dir.exists():
        return pd.DataFrame()
    rows: list[dict] = []
    for rep in sorted(run_dir.glob("rep*")):
        fp = next(iter(rep.rglob("predictions.h5")), None)
        if fp is None:
            continue
        with h5py.File(fp, "r") as f:
            if eval_stage not in f:
                continue
            grp = f[eval_stage]
            logits = grp["logits"][:]
            y_true = grp["y_true"][:].astype(int)
            split = grp["split"][:]
            eid0 = grp["entity_id_0"][:]
            n_folds, n_epochs, n_samples, n_heads, n_outputs = logits.shape
            if n_outputs != 2:
                continue
            ep, head = n_epochs - 1, n_heads - 1
            for fold in range(n_folds):
                labels = np.array(
                    [
                        s.decode() if isinstance(s, bytes) else str(s)
                        for s in split[fold]
                    ]
                )
                test_mask = labels == "test"
                if not test_mask.any():
                    continue
                lg = logits[fold, ep, test_mask, head, :]
                e = np.exp(lg - lg.max(axis=1, keepdims=True))
                p = e / e.sum(axis=1, keepdims=True)
                for k, idx in enumerate(np.where(test_mask)[0]):
                    rows.append(
                        {
                            "rep": rep.name,
                            "fold": fold,
                            "patient_id": int(eid0[idx]),
                            "y_true": int(y_true[idx]),
                            "prob_glaucoma": float(p[k, 1]),
                        }
                    )
    return pd.DataFrame(rows)


C_NORMAL_VIOLIN = "#4c72b0"
C_GLAUCOMA_VIOLIN = "#c44e52"


def _draw_panel(ax, df: pd.DataFrame, vfi: pd.DataFrame, label: str):
    df = df.merge(vfi[["patient_id", "severity"]], on="patient_id", how="left")
    df["severity"] = df["severity"].fillna("unknown")
    rng = np.random.default_rng(42)
    x_pos = {0: 0.0, 1: 1.0}
    jitter_scale = 0.18

    # Violin density behind everything (per true class)
    data_by_class = [
        df.loc[df["y_true"] == cls, "prob_glaucoma"].values for cls in [0, 1]
    ]
    if all(len(d) > 0 for d in data_by_class):
        vp = ax.violinplot(
            data_by_class,
            positions=[0, 1],
            widths=0.7,
            showmedians=False,
            showextrema=False,
        )
        for body, color in zip(vp["bodies"], [C_NORMAL_VIOLIN, C_GLAUCOMA_VIOLIN]):
            body.set_facecolor(color)
            body.set_alpha(0.30)
            body.set_edgecolor("none")
            body.set_zorder(2)

    for sev in SEV_ORDER:
        mask = df["severity"] == sev
        if not mask.any():
            continue
        sub = df[mask]
        jitter = rng.uniform(-jitter_scale, jitter_scale, len(sub))
        x = np.array([x_pos[int(v)] for v in sub["y_true"]]) + jitter
        ax.scatter(
            x,
            sub["prob_glaucoma"].values,
            c=SEV_COLORS[sev],
            s=SEV_SIZE[sev],
            alpha=SEV_ALPHA[sev],
            linewidths=0,
            zorder=3,
        )
    xtick_labels = []
    for cls, xc in x_pos.items():
        vals = df.loc[df["y_true"] == cls, "prob_glaucoma"]
        if not len(vals):
            xtick_labels.append("Normal" if cls == 0 else "Glaucoma")
            continue
        med = float(np.median(vals))
        ax.plot(
            [xc - jitter_scale - 0.04, xc + jitter_scale + 0.04],
            [med, med],
            color="#222",
            lw=2.0,
            zorder=5,
        )
        if cls == 0:
            rate = (vals <= 0.5).mean() * 100
            xtick_labels.append(f"Normal\nTN {rate:.0f}%")
        else:
            rate = (vals > 0.5).mean() * 100
            xtick_labels.append(f"Glaucoma\nTP {rate:.0f}%")
    ax.axhline(0.5, color="#888", lw=1.2, ls="--", alpha=0.7, zorder=2)
    ax.set_xticks([0, 1])
    ax.set_xticklabels(xtick_labels, fontsize=9)
    ax.set_ylim(-0.04, 1.04)
    ax.set_xlim(-0.55, 1.55)
    ax.set_title(label, fontsize=10.5, fontweight="bold")
    ax.grid(axis="y", alpha=0.3, zorder=1)

    fold_aucs = []
    for _, g in df.groupby(["rep", "fold"]):
        if g["y_true"].nunique() < 2:
            continue
        try:
            fold_aucs.append(roc_auc_score(g["y_true"], g["prob_glaucoma"]))
        except Exception:
            pass
    if fold_aucs:
        ax.text(
            0.66,
            0.0,
            f"AUC = {np.mean(fold_aucs):.3f} ± {np.std(fold_aucs):.3f}",
            transform=ax.transAxes,
            ha="right",
            va="bottom",
            fontsize=8.5,
            color="#333",
            bbox=dict(facecolor="white", alpha=0.7, edgecolor="none", pad=2),
        )


def render() -> None:
    vfi = load_vfi()
    fig, axes = plt.subplots(2, 3, figsize=(13, 11), sharey=True)
    fig.patch.set_facecolor("#e8e8e8")
    fig.suptitle("Single → Bilateral Aggregation Lift — refuge V2-M backbone",
                 fontsize=13, fontweight="bold")

    for ri, row in enumerate(GRID):
        for ci, (lbl, path, stage) in enumerate(row):
            ax = axes[ri, ci]
            ax.set_facecolor("#e8e8e8")
            df = collect_predictions(path, stage)
            if len(df):
                _draw_panel(ax, df, vfi, lbl)
                print(f"  [{ri},{ci}] {lbl:<22s}  n={len(df):>5d}")
            else:
                ax.text(
                    0.5,
                    0.5,
                    "(pending)",
                    ha="center",
                    va="center",
                    fontsize=12,
                    color="#888",
                    transform=ax.transAxes,
                )
                ax.set_xticks([])
                ax.set_yticks([])
                ax.set_title(lbl, fontsize=10.5, fontweight="bold")
                print(f"  [{ri},{ci}] {lbl:<22s}  no data yet")

    for ri in range(2):
        axes[ri, 0].set_ylabel("Predicted P(Glaucoma)", fontsize=10.5)

    legend_patches = [
        mpatches.Patch(color=SEV_COLORS[s], label=SEV_LABELS[s])
        for s in ["normal", "early", "moderate", "severe", "unknown"]
    ]
    fig.legend(
        handles=legend_patches,
        fontsize=9,
        loc="lower center",
        ncol=len(legend_patches),
        framealpha=0.75,
        bbox_to_anchor=(0.5, -0.005),
    )

    fig.tight_layout(rect=[0, 0.04, 1, 0.97])
    OUT.parent.mkdir(parents=True, exist_ok=True)
    fig.savefig(OUT, dpi=180, bbox_inches="tight")
    plt.close(fig)
    print(f"saved {OUT}")


if __name__ == "__main__":
    render()
