"""評価指標（SPEC_02「評価指標」「交差検証の実行」）.

すべて入力 → 出力の純関数．クラスの軸は常に `classes.py` の固定 ID 順（混同行列の軸をぶらさない）．
出力は集計値だけであり，画像単位の値・患者 ID は含まない．
"""

from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import Any

import numpy as np
from sklearn.metrics import confusion_matrix, f1_score, precision_recall_fscore_support

from tias_judge_model.data.classes import CLASS_SLUGS, NUM_CLASSES, label_to_id

# 淡紅（正常）のクラス ID．mild の予測の内訳で「淡紅と予測された割合」に使う．
LIGHT_RED_ID: int = label_to_id("light_red")

# 評価の部分集合と含める程度（SPEC_02「評価の出し方」）．
SUBSETS: dict[str, tuple[str, ...]] = {
    "all": ("strong", "mild", "normal"),
    "common": ("strong", "normal"),
    "mild": ("mild",),
}

# fold 間の平均・標準偏差を取らないキー（件数・行列）．
_NON_AVERAGED_KEYS = frozenset({"n", "support", "confusion_matrix"})


def macro_f1(y_true: np.ndarray, y_pred: np.ndarray) -> float:
    """macro F1 を返す（`y_true` に現れるクラスで平均する）.

    `y_true` に現れないクラス（例: mild の部分集合の淡紅）を平均に含めると，そのクラスへの予測が
    それだけで F1 = 0 として平均を下げるため，正解側に現れるクラスだけで平均する．

    Args:
        y_true: `(N,)` の正解クラス ID．
        y_pred: `(N,)` の予測クラス ID．

    Returns:
        macro F1（N = 0 なら 0.0）．
    """
    if len(y_true) == 0:
        return 0.0
    labels = np.unique(y_true)
    return float(f1_score(y_true, y_pred, labels=labels, average="macro", zero_division=0))


def classification_metrics(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, Any]:
    """分類指標（macro F1・accuracy・クラス別 precision / recall / F1・混同行列）を返す.

    Args:
        y_true: `(N,)` の正解クラス ID（0〜4）．
        y_pred: `(N,)` の予測クラス ID（0〜4）．

    Returns:
        `{"n", "macro_f1", "accuracy", "per_class": {slug: {precision, recall, f1, support}},
        "confusion_matrix": [[...]]}`．混同行列は行が正解・列が予測でクラス ID 順（5×5）．
        クラス別は `y_true` に現れるクラスだけを含む．
    """
    y_true = np.asarray(y_true, dtype=np.int64)
    y_pred = np.asarray(y_pred, dtype=np.int64)
    all_labels = list(range(NUM_CLASSES))
    cm = confusion_matrix(y_true, y_pred, labels=all_labels)
    precision, recall, f1, support = precision_recall_fscore_support(
        y_true, y_pred, labels=all_labels, zero_division=0
    )
    present = set(np.unique(y_true).tolist())
    per_class = {
        CLASS_SLUGS[c]: {
            "precision": float(precision[c]),
            "recall": float(recall[c]),
            "f1": float(f1[c]),
            "support": int(support[c]),
        }
        for c in all_labels
        if c in present
    }
    return {
        "n": int(len(y_true)),
        "macro_f1": macro_f1(y_true, y_pred),
        "accuracy": float(np.mean(y_true == y_pred)) if len(y_true) else 0.0,
        "per_class": per_class,
        "confusion_matrix": cm.astype(int).tolist(),
    }


def mild_prediction_breakdown(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, Any]:
    """mild の画像の予測の内訳（淡紅・元のクラス・その他と予測された割合）を返す.

    淡紅と予測することを誤りと断定せず，割合として報告する（SPEC_02「程度（ややあり）の扱い」）．
    淡紅には mild が無いため，元のクラスと淡紅は重ならない．

    Args:
        y_true: `(N,)` の mild の画像の正解クラス ID．
        y_pred: `(N,)` の予測クラス ID．

    Returns:
        `{"overall": {...}, "per_class": {slug: {...}}}`．各値は
        `{"n", "light_red", "original", "other"}`（割合．N = 0 なら None）．
        クラス別は `y_true` に現れるクラスだけを含む．
    """
    y_true = np.asarray(y_true, dtype=np.int64)
    y_pred = np.asarray(y_pred, dtype=np.int64)
    per_class = {
        CLASS_SLUGS[c]: _breakdown(y_true[y_true == c], y_pred[y_true == c])
        for c in range(NUM_CLASSES)
        if np.any(y_true == c)
    }
    return {"overall": _breakdown(y_true, y_pred), "per_class": per_class}


def _breakdown(y_true: np.ndarray, y_pred: np.ndarray) -> dict[str, Any]:
    n = len(y_true)
    if n == 0:
        return {"n": 0, "light_red": None, "original": None, "other": None}
    to_light_red = y_pred == LIGHT_RED_ID
    to_original = (y_pred == y_true) & ~to_light_red
    other = ~to_light_red & ~to_original
    return {
        "n": int(n),
        "light_red": float(np.mean(to_light_red)),
        "original": float(np.mean(to_original)),
        "other": float(np.mean(other)),
    }


def subset_metrics(
    y_true: np.ndarray, y_pred: np.ndarray, severities: Sequence[str]
) -> dict[str, Any]:
    """部分集合 `all`・`common`・`mild` ごとの指標を返す（SPEC_02「評価の出し方」）.

    Args:
        y_true: `(N,)` の正解クラス ID．
        y_pred: `(N,)` の予測クラス ID．
        severities: 長さ N の各画像の程度（`strong`・`mild`・`normal`）．

    Returns:
        部分集合名 → `classification_metrics` の結果（該当 0 件なら None）．
        `mild` には予測の内訳 `prediction_breakdown` を加える．
    """
    y_true = np.asarray(y_true, dtype=np.int64)
    y_pred = np.asarray(y_pred, dtype=np.int64)
    sev = np.asarray(severities)
    result: dict[str, Any] = {}
    for name, included in SUBSETS.items():
        mask = np.isin(sev, included)
        if not np.any(mask):
            result[name] = None
            continue
        metrics = classification_metrics(y_true[mask], y_pred[mask])
        if name == "mild":
            metrics["prediction_breakdown"] = mild_prediction_breakdown(y_true[mask], y_pred[mask])
        result[name] = metrics
    return result


def mean_std(values: Sequence[float]) -> dict[str, float | int | None]:
    """値の平均と標準偏差（不偏．1 件なら 0）を返す.

    Args:
        values: fold ごとの値．

    Returns:
        `{"mean", "std", "n"}`（0 件なら mean・std は None）．
    """
    arr = np.asarray(values, dtype=np.float64)
    if arr.size == 0:
        return {"mean": None, "std": None, "n": 0}
    std = float(np.std(arr, ddof=1)) if arr.size > 1 else 0.0
    return {"mean": float(np.mean(arr)), "std": std, "n": int(arr.size)}


def aggregate_folds(fold_values: Sequence[Mapping[str, Any] | None]) -> dict[str, Any] | None:
    """fold ごとの入れ子の指標（同じ形の dict）から，数値の葉ごとに平均 ± 標準偏差を作る.

    件数（`n`・`support`）と混同行列は平均しない．ある fold に無いキー（例: その fold の
    mild に現れないクラス）は，そのキーを持つ fold だけで集計する（`n` に fold 数が入る）．

    Args:
        fold_values: fold ごとの指標（None の fold は除く）．

    Returns:
        同じ形の dict で，数値の葉を `{"mean", "std", "n"}` に置き換えたもの．
        有効な fold が無ければ None．
    """
    present = [v for v in fold_values if v is not None]
    if not present:
        return None
    keys: list[str] = []
    for v in present:
        keys.extend(k for k in v if k not in keys)
    result: dict[str, Any] = {}
    for key in keys:
        if key in _NON_AVERAGED_KEYS:
            continue
        items = [v.get(key) for v in present]
        items = [x for x in items if x is not None]
        if not items:
            continue
        if all(isinstance(x, Mapping) for x in items):
            nested = aggregate_folds(items)
            if nested:
                result[key] = nested
        elif all(isinstance(x, int | float) and not isinstance(x, bool) for x in items):
            result[key] = mean_std([float(x) for x in items])
    return result


def sum_confusion_matrices(matrices: Sequence[Sequence[Sequence[int]]]) -> list[list[int]]:
    """混同行列（同じ形）の合計を返す.

    Args:
        matrices: 混同行列のリスト（各 5×5）．

    Returns:
        合計の混同行列（5×5．空なら 0 行列）．
    """
    total = np.zeros((NUM_CLASSES, NUM_CLASSES), dtype=np.int64)
    for m in matrices:
        total += np.asarray(m, dtype=np.int64)
    return total.astype(int).tolist()
