"""回収した交差検証の結果（runs/*_cv_*/metrics.json）を集計して Markdown の表にする.

`tias-remote pull` で手元に回収した実験ディレクトリを読む（metrics.json と config.yaml は
集計値のみで個人情報を含まない）．モデル × 学習率 × 条件（ややあり込み／除外）ごとに，
全 seed × 5 fold の fold ごとの値の平均 ± 標準偏差と，同じ seed・同じ fold どうしの
対応比較（除外 − 込み）を出す．結果は標準出力（Markdown）と runs/_cv_summary.json に書く．

使い方:
    uv run python scripts/summarize_cv.py [runs]
"""

import json
import math
import sys
from collections import defaultdict
from pathlib import Path

import yaml

CLASSES = ["pale", "light_red", "red", "dark_red", "purple"]
JP = {"pale": "淡白", "light_red": "淡紅", "red": "紅", "dark_red": "暗赤紅", "purple": "紫"}
MODEL_LABEL = {
    "convnext_tiny": "ConvNeXt-Tiny",
    "efficientnet_b0": "EfficientNet-B0",
    "resnet50": "ResNet-50",
    "vit_small_patch16_224": "ViT-Small",
}
MODEL_ORDER = list(MODEL_LABEL)
CONDITIONS = ("with_mild", "without_mild")
MEASURES = ("none", "inverse", "sqrt_inverse", "balanced")
MEASURE_LABEL = {
    "none": "",
    "inverse": "・重み inverse",
    "sqrt_inverse": "・重み sqrt",
    "balanced": "・balanced",
}
# 不均衡対策の対応比較で recall の差を示すクラス（少数クラス → 多数クラスの順）
COMPARED_RECALL_CLASSES = ("pale", "purple", "light_red", "dark_red")
CONDITION_LABEL = {"with_mild": "込み", "without_mild": "除外"}

MeanStd = tuple[float, float, int]


def mean_std(xs: list[float]) -> MeanStd:
    """平均・不偏標準偏差・件数を返す."""
    xs = [x for x in xs if x is not None and not math.isnan(x)]
    if not xs:
        return (float("nan"), float("nan"), 0)
    m = sum(xs) / len(xs)
    s = math.sqrt(sum((x - m) ** 2 for x in xs) / (len(xs) - 1)) if len(xs) > 1 else 0.0
    return (m, s, len(xs))


def fmt(ms: MeanStd, digits: int = 3) -> str:
    """平均 ± 標準偏差を文字列にする."""
    m, s, n = ms
    return "—" if n == 0 else f"{m:.{digits}f} ± {s:.{digits}f}"


def label(key: tuple[str, float, str]) -> str:
    """（モデル名, 学習率, 不均衡対策）の表示名."""
    name, lr, measure = key
    return f"{MODEL_LABEL[name]}（lr {lr:g}{MEASURE_LABEL[measure]}）"


def imbalance_measure(metrics: dict) -> str:
    """不均衡対策の種類（対策の記録が無い古い run は対策なし）."""
    imb = metrics.get("imbalance") or {}
    if imb.get("sampler") == "balanced":
        return "balanced"
    weighting = imb.get("class_weighting", "none")
    return weighting if weighting in MEASURES else "none"


def sort_key(key: tuple[str, float, str]) -> tuple[int, float, int]:
    """表の並び順（モデル → 学習率 → 対策）."""
    return (MODEL_ORDER.index(key[0]), key[1], MEASURES.index(key[2]))


def load_runs(root: Path) -> list[dict]:
    """実験ディレクトリを読む（対象外のモデルは飛ばす）."""
    runs = []
    for mpath in sorted(root.glob("*_cv_*/metrics.json")):
        metrics = json.loads(mpath.read_text(encoding="utf-8"))
        doc = yaml.safe_load((mpath.parent / "config.yaml").read_text(encoding="utf-8"))
        conf = doc.get("config", doc)
        model = metrics["environment"]["model_name"]
        if model not in MODEL_LABEL:
            continue
        cond = "with_mild" if "mild" in metrics["severities_trained"] else "without_mild"
        runs.append(
            {
                "run_id": metrics["run_id"],
                "model": (model, float(conf["train"]["lr"]), imbalance_measure(metrics)),
                "cond": cond,
                "seed": conf["split"]["seed"],
                "m": metrics,
            }
        )
    return runs


def fold_values(run: dict, subset: str, key: str) -> list[float]:
    """fold ごとの指標の値."""
    return [f["subsets"][subset][key] for f in run["m"]["folds"]]


def fold_class_recall(run: dict, subset: str, cls: str) -> list[float]:
    """fold ごとのクラス別再現率（正解に現れた fold のみ）."""
    out = []
    for f in run["m"]["folds"]:
        per_class = f["subsets"][subset].get("per_class", {})
        if cls in per_class:
            out.append(per_class[cls]["recall"])
    return out


def mild_to_normal(run: dict) -> float:
    """mild の画像を淡紅と予測した割合（全 fold をまとめた値）."""
    return run["m"]["pooled"]["mild"]["prediction_breakdown"]["overall"]["light_red"]


def pooled_folds(runs: list[dict], subset: str, key: str) -> MeanStd:
    """複数 run の fold ごとの値をまとめて平均 ± 標準偏差にする."""
    return mean_std([v for r in runs for v in fold_values(r, subset, key)])


def measure_comparison(keys: list, groups: dict) -> list[str]:
    """不均衡対策あり − 対策なし の対応比較（同じモデル・学習率・条件・seed・fold）."""
    recall_headers = [f"{JP[c]} recall の差" for c in COMPARED_RECALL_CLASSES]
    headers = ["モデル", "条件", "fold 数", "common F1 の差", "上回った fold", *recall_headers]
    lines = [
        "| " + " | ".join(headers) + " |",
        "|" + " --- |" * len(headers),
    ]
    found = False
    for model in keys:
        if model[2] == "none":
            continue
        base_key = (model[0], model[1], "none")
        for cond in CONDITIONS:
            treated = {r["seed"]: r for r in groups.get((model, cond), [])}
            base = {r["seed"]: r for r in groups.get((base_key, cond), [])}
            seeds = sorted(set(treated) & set(base))
            if not seeds:
                continue
            found = True
            f1_diffs: list[float] = []
            recall_diffs: dict[str, list[float]] = {c: [] for c in COMPARED_RECALL_CLASSES}
            for seed in seeds:
                a = fold_values(base[seed], "common", "macro_f1")
                b = fold_values(treated[seed], "common", "macro_f1")
                f1_diffs += [y - x for x, y in zip(a, b, strict=True)]
                for c in COMPARED_RECALL_CLASSES:
                    ra = fold_class_recall(base[seed], "common", c)
                    rb = fold_class_recall(treated[seed], "common", c)
                    if len(ra) == len(rb):
                        recall_diffs[c] += [y - x for x, y in zip(ra, rb, strict=True)]
            wins = sum(1 for d in f1_diffs if d > 0)
            n = len(f1_diffs)
            cells = [label(model), CONDITION_LABEL[cond], str(n), fmt(mean_std(f1_diffs))]
            cells.append(f"{wins} / {n}")
            cells += [fmt(mean_std(recall_diffs[c]), 2) for c in COMPARED_RECALL_CLASSES]
            lines.append("| " + " | ".join(cells) + " |")
    if not found:
        return []
    lines.append(
        "\n※ 不均衡対策あり − 対策なし（同じモデル・学習率・条件・seed・fold どうし）．"
        "recall は common でのクラス別再現率．\n"
    )
    return lines


def main(root: Path) -> None:
    """集計して表を出力する."""
    runs = load_runs(root)
    groups: dict = defaultdict(list)
    for r in runs:
        groups[(r["model"], r["cond"])].append(r)
    keys = sorted({r["model"] for r in runs}, key=sort_key)
    seeds = ", ".join(sorted({str(r["seed"]) for r in runs}))

    lines = [f"対象: {len(runs)} 本（seed {seeds}）\n"]
    lines.append(
        "| モデル | 条件 | seed 数 | common macro F1 | common 正解率 | all macro F1"
        " | mild→淡紅 | 1 本の所要（秒） |"
    )
    lines.append("| --- | --- | --- | --- | --- | --- | --- | --- |")
    summary = {}
    for model in keys:
        for cond in CONDITIONS:
            rs = groups.get((model, cond), [])
            if not rs:
                continue
            row = {
                "common_f1": pooled_folds(rs, "common", "macro_f1"),
                "common_acc": pooled_folds(rs, "common", "accuracy"),
                "all_f1": pooled_folds(rs, "all", "macro_f1"),
                "mild_to_normal": mean_std([mild_to_normal(r) for r in rs]),
                "seconds": mean_std([r["m"]["timing"]["total_seconds"] for r in rs]),
            }
            cells = [
                label(model),
                CONDITION_LABEL[cond],
                str(len(rs)),
                fmt(row["common_f1"]),
                fmt(row["common_acc"]),
                fmt(row["all_f1"]),
                fmt(row["mild_to_normal"], 2),
                fmt(row["seconds"], 0),
            ]
            lines.append("| " + " | ".join(cells) + " |")
            summary[f"{model[0]}/lr{model[1]:g}/{model[2]}/{cond}"] = row
    lines.append(
        "\n※ macro F1・正解率は全 seed × 5 fold の fold ごとの値の平均 ± 標準偏差．"
        "mild→淡紅 は run ごとの（全 fold をまとめた）割合の平均 ± 標準偏差．\n"
    )

    lines.append("| モデル | 条件 | " + " | ".join(JP[c] for c in CLASSES) + " |")
    lines.append("| --- | --- | " + " | ".join("---" for _ in CLASSES) + " |")
    for model in keys:
        for cond in CONDITIONS:
            rs = groups.get((model, cond), [])
            if not rs:
                continue
            cells = [
                fmt(mean_std([v for r in rs for v in fold_class_recall(r, "common", c)]), 2)
                for c in CLASSES
            ]
            lines.append(
                f"| {label(model)} | {CONDITION_LABEL[cond]} | " + " | ".join(cells) + " |"
            )
    lines.append(
        "\n※ common（「あり」と淡紅）での各クラスの再現率（fold ごとの値の平均 ± 標準偏差）．\n"
    )

    lines.append(
        "| モデル | 対応のある fold 数 | common F1 の差（除外 − 込み） | 除外が上回った fold |"
    )
    lines.append("| --- | --- | --- | --- |")
    paired = {}
    for model in keys:
        with_mild = {r["seed"]: r for r in groups.get((model, "with_mild"), [])}
        without = {r["seed"]: r for r in groups.get((model, "without_mild"), [])}
        diffs = []
        for seed in sorted(set(with_mild) & set(without)):
            a = fold_values(with_mild[seed], "common", "macro_f1")
            b = fold_values(without[seed], "common", "macro_f1")
            diffs += [y - x for x, y in zip(a, b, strict=True)]
        if diffs:
            wins = sum(1 for d in diffs if d > 0)
            n = len(diffs)
            lines.append(f"| {label(model)} | {n} | {fmt(mean_std(diffs))} | {wins} / {n} |")
            paired[f"{model[0]}/lr{model[1]:g}/{model[2]}"] = {
                "diff": mean_std(diffs),
                "wins": wins,
                "n": n,
            }
    lines.append(
        "\n※ 同じ seed なら「込み」と「除外」の分割は完全に同じ（SPEC_02）なので，"
        "fold ごとの差を取っている．\n"
    )

    lines += measure_comparison(keys, groups)

    print("\n".join(lines))
    out = {"summary": summary, "paired": paired, "runs": [r["run_id"] for r in runs]}
    (root / "_cv_summary.json").write_text(
        json.dumps(out, ensure_ascii=False, indent=2), encoding="utf-8"
    )


if __name__ == "__main__":
    main(Path(sys.argv[1] if len(sys.argv) > 1 else "runs"))
