"""データセット集計のテスト（SPEC_01「データセット集計の出力」・SPEC_02「程度（ややあり）の扱い」）.

- 出力には集計値だけを入れる．**画像単位の値・患者 ID・ファイル名を出さない**（ログも同様）
- 読めない画像・欠けたファイルがあっても止めず，件数として数える
- 舌領域（RGB がすべて 0 でない画素）の Lab を画像ごとに平均し，グループごとに集計する
- 各 mild グループについて「淡紅（light_red_normal）との ΔE」と「同じクラスの strong との ΔE」を
  並べる
- 条件（ややあり込み／除外）ごとの件数と，既定の設定で分割したときの各分割・グループの件数

画像はすべて tmp_path に作る合成画像（臨床データは使わない）．
"""

from __future__ import annotations

import json
import logging
from pathlib import Path
from typing import Any

import numpy as np
import pytest
from PIL import Image

from tias_judge_model.data.classes import ALL_DIR_NAMES, CLASS_SLUGS, parse_dir_name
from tias_judge_model.data.color import delta_e_cie76, rgb8_to_lab
from tias_judge_model.data.manifest import ManifestRecord
from tias_judge_model.data.survey import (
    ImageStats,
    collect_image_stats,
    measure_pixels,
    summarize_dataset,
)

SECRET = "SECRETPT"
SPLIT_KW: dict[str, Any] = {"seed": 42, "val_ratio": 0.15, "test_ratio": 0.15, "n_folds": 5}

# 合成データ（scripts/make_synthetic_dataset.py）に近い基準色
BASE = {
    "pale": (235, 205, 200),
    "light_red": (225, 140, 140),
    "red": (215, 40, 45),
    "dark_red": (120, 20, 30),
    "purple": (110, 50, 140),
}


def _uniform(rgb: tuple[int, int, int], *, width: int = 20, height: int = 10) -> np.ndarray:
    """全面が 1 色の舌領域の画像（`(H, W, 3)`, uint8）."""
    pixels = np.zeros((height, width, 3), dtype=np.uint8)
    pixels[:, :] = rgb
    return pixels


def _half_background(rgb: tuple[int, int, int]) -> np.ndarray:
    """左半分が背景（黒），右半分が 1 色の画像."""
    pixels = np.zeros((10, 20, 3), dtype=np.uint8)
    pixels[:, 10:] = rgb
    return pixels


def _stats(pixels: np.ndarray) -> ImageStats:
    return measure_pixels(pixels, mode="RGB", has_alpha=False)


def _record(group: str, index: int, image_index: int = 0) -> ManifestRecord:
    label, severity = parse_dir_name(group)
    patient_id = f"{SECRET}{group}{index:04d}"
    return ManifestRecord(
        image_path=f"{group}/{patient_id}_{image_index}.png",
        patient_id=patient_id,
        label=label,
        severity=severity,
    )


def _blend(a: tuple[int, int, int], b: tuple[int, int, int], t: float) -> tuple[int, int, int]:
    """a と b を (1 - t) : t で混ぜた色."""
    return tuple(int(round((1 - t) * x + t * y)) for x, y in zip(a, b, strict=True))  # type: ignore[return-value]


def _dataset(per_group: int = 6) -> tuple[list[ManifestRecord], list[ImageStats], dict]:
    """全 9 グループに per_group 人（1 人 1 枚）の合成データ．グループごとの色を返す."""
    normal = BASE["light_red"]
    colors: dict[str, tuple[int, int, int]] = {}
    for group in ALL_DIR_NAMES:
        label, severity = parse_dir_name(group)
        if severity == "mild":
            # red_mild・purple_mild は淡紅寄り，pale_mild・dark_red_mild は strong 寄り
            t = 0.8 if label in ("red", "purple") else 0.1
            colors[group] = _blend(BASE[label], normal, t)
        else:
            colors[group] = BASE[label]
    records: list[ManifestRecord] = []
    stats: list[ImageStats] = []
    for group in ALL_DIR_NAMES:
        for i in range(per_group):
            records.append(_record(group, i))
            stats.append(_stats(_uniform(colors[group], width=20 + i, height=10 + i)))
    return records, stats, colors


def _summarize(records: list[ManifestRecord], stats: list[ImageStats], **kw: Any) -> dict:
    return summarize_dataset(
        records, stats, **{**SPLIT_KW, **kw}, configured_severities=("strong", "mild", "normal")
    )


def _walk_lists(value: Any):
    if isinstance(value, dict):
        for v in value.values():
            yield from _walk_lists(v)
    elif isinstance(value, list):
        yield value
        for v in value:
            yield from _walk_lists(v)


# ---------------------------------------------------------------------------
# 個人情報・画像単位の値を出さない
# ---------------------------------------------------------------------------


class TestNoPersonalData:
    def test_summary_has_no_patient_id_or_filename(self) -> None:
        records, stats, _ = _dataset()
        summary = _summarize(records, stats)
        text = json.dumps(summary, ensure_ascii=False)
        assert SECRET not in text
        assert ".png" not in text
        for r in records:
            assert r.patient_id not in text
            assert r.image_path not in text
        # 分割の確認も実際に行われていること（空振りで個人情報が出ないのではない）
        assert "error" not in summary["split"]["single"]
        assert all("error" not in fold for fold in summary["split"]["kfold"])

    def test_summary_has_no_per_image_values(self) -> None:
        # 画像単位の値の列（画像数と同じ長さのリスト等）を含まない
        records, stats, _ = _dataset()
        summary = _summarize(records, stats)
        for lst in _walk_lists(summary):
            assert len(lst) < len(records)
        # JSON にできる（集計値だけの dict）
        json.dumps(summary)

    def test_logs_have_no_patient_id_or_filename(
        self, tmp_path: Path, caplog: pytest.LogCaptureFixture
    ) -> None:
        caplog.set_level(logging.DEBUG)
        records, _, colors = _dataset(per_group=2)
        images = tmp_path / "images"
        for r in records[:6]:
            path = images / r.image_path
            path.parent.mkdir(parents=True, exist_ok=True)
            Image.fromarray(_uniform(colors[f"{r.label}_{r.severity}"])).save(path)
        broken = images / records[6].image_path
        broken.parent.mkdir(parents=True, exist_ok=True)
        broken.write_bytes(b"not a png")
        # records[7:] は欠けたファイル
        stats = collect_image_stats(records, images)
        _summarize(records, stats)
        assert SECRET not in caplog.text
        assert ".png" not in caplog.text


# ---------------------------------------------------------------------------
# 読み込み（読めない画像・欠けたファイルで止まらない）
# ---------------------------------------------------------------------------


class TestLoading:
    def _write_images(self, images: Path) -> list[ManifestRecord]:
        recs = [_record("red_strong", i) for i in range(6)]
        paths = [images / r.image_path for r in recs]
        paths[0].parent.mkdir(parents=True)
        Image.fromarray(_uniform((200, 50, 50))).save(paths[0])  # RGB
        rgba = np.zeros((10, 20, 4), dtype=np.uint8)
        rgba[..., 0] = 200
        rgba[..., 3] = 255
        Image.fromarray(rgba, mode="RGBA").save(paths[1])  # アルファ付き
        Image.fromarray(np.full((10, 20), 128, dtype=np.uint8), mode="L").save(paths[2])  # 濃淡
        paths[3].write_bytes(b"\x89PNG\r\n\x1a\n broken")  # 壊れた PNG
        paths[4].write_bytes(b"")  # 空ファイル
        # paths[5] は作らない（欠けたファイル）
        return recs

    def test_unreadable_and_missing_are_counted_not_raised(self, tmp_path: Path) -> None:
        images = tmp_path / "images"
        records = self._write_images(images)
        stats = collect_image_stats(records, images)
        assert len(stats) == len(records)
        summary = _summarize(records, stats)
        loading = summary["loading"]
        assert loading["readable"] == 3
        assert loading["unreadable"] == 3
        assert loading["missing"] == 1
        assert loading["with_alpha"] == 1
        assert loading["non_rgb_mode"] == 2  # RGBA と L
        # 読めない画像も件数（manifest 上の画像数）には入る
        assert summary["counts"]["groups"]["red_strong"]["images"] == 6
        # 色の集計は読めた画像だけ
        assert summary["lab"]["groups"]["red_strong"]["n"] == 3

    def test_size_summary(self) -> None:
        records = [_record("red_strong", i) for i in range(3)]
        stats = [
            _stats(_uniform((200, 50, 50), width=w, height=h))
            for w, h in [(10, 40), (30, 20), (50, 30)]
        ]
        size = _summarize(records, stats)["size"]
        assert size["width"] == pytest.approx({"min": 10, "max": 50, "mean": 30, "median": 30})
        assert size["height"] == pytest.approx({"min": 20, "max": 40, "mean": 30, "median": 30})

    def test_tongue_ratio_excludes_background(self) -> None:
        records = [_record("red_strong", 0), _record("red_strong", 1)]
        stats = [_stats(_half_background((200, 50, 50))), _stats(_uniform((200, 50, 50)))]
        ratio = _summarize(records, stats)["tongue_ratio"]
        assert ratio["total"] == pytest.approx({"min": 0.5, "mean": 0.75, "max": 1.0})


# ---------------------------------------------------------------------------
# 色（Lab）と色差
# ---------------------------------------------------------------------------


class TestLabAndDeltaE:
    def test_background_does_not_affect_lab(self) -> None:
        records = [_record("red_strong", 0)]
        stats = [_stats(_half_background((255, 255, 255)))]
        lab = _summarize(records, stats)["lab"]["groups"]["red_strong"]
        assert lab["n"] == 1
        assert lab["L"]["mean"] == pytest.approx(100.0, abs=1e-3)
        assert lab["a"]["mean"] == pytest.approx(0.0, abs=1e-3)
        assert lab["b"]["mean"] == pytest.approx(0.0, abs=1e-3)

    def test_group_statistics_over_image_means(self) -> None:
        # 画像ごとの平均 Lab をグループで集計する．灰色 3 枚（L* が既知）
        grays = [50, 119, 200]
        records = [_record("pale_strong", i) for i in range(3)]
        stats = [_stats(_uniform((v, v, v))) for v in grays]
        expected = [float(rgb8_to_lab(np.array([v, v, v], dtype=np.uint8))[0]) for v in grays]
        lab = _summarize(records, stats)["lab"]["groups"]["pale_strong"]
        assert lab["n"] == 3
        assert lab["L"]["mean"] == pytest.approx(np.mean(expected), abs=1e-3)
        assert lab["L"]["median"] == pytest.approx(expected[1], abs=1e-3)
        assert lab["L"]["p10"] == pytest.approx(np.percentile(expected, 10), abs=1e-3)
        assert lab["L"]["p90"] == pytest.approx(np.percentile(expected, 90), abs=1e-3)
        assert lab["L"]["std"] is not None and lab["L"]["std"] > 0
        assert lab["a"]["mean"] == pytest.approx(0.0, abs=1e-3)
        # 画像の無いグループは件数 0
        assert _summarize(records, stats)["lab"]["groups"]["red_mild"]["n"] == 0

    def test_mild_comparison_with_known_colors(self) -> None:
        records, stats, colors = _dataset()
        summary = _summarize(records, stats)
        lab_of = {g: rgb8_to_lab(np.array(c, dtype=np.uint8)) for g, c in colors.items()}
        items = {item["group"]: item for item in summary["delta_e"]["mild_comparison"]}
        assert set(items) == {"pale_mild", "red_mild", "dark_red_mild", "purple_mild"}
        for group, item in items.items():
            label, _ = parse_dir_name(group)
            to_normal = delta_e_cie76(lab_of[group], lab_of["light_red_normal"])
            to_strong = delta_e_cie76(lab_of[group], lab_of[f"{label}_strong"])
            assert item["delta_e_to_normal"] == pytest.approx(to_normal, abs=1e-3), group
            assert item["delta_e_to_strong"] == pytest.approx(to_strong, abs=1e-3), group
        # 淡紅寄りに作った mild は淡紅に，strong 寄りに作った mild は strong に近い
        assert items["red_mild"]["closer_to"] == "light_red_normal"
        assert items["purple_mild"]["closer_to"] == "light_red_normal"
        assert items["pale_mild"]["closer_to"] == "pale_strong"
        assert items["dark_red_mild"]["closer_to"] == "dark_red_strong"

    def test_group_mean_is_average_of_images(self) -> None:
        # red_mild に色の異なる 2 枚．グループ平均は画像ごとの Lab の平均で，ΔE はそれとの距離
        normal = (225, 140, 140)
        c1, c2 = (220, 100, 100), (200, 60, 60)
        records = [
            _record("light_red_normal", 0),
            _record("red_mild", 0),
            _record("red_mild", 1),
            _record("red_strong", 0),
        ]
        stats = [_stats(_uniform(c)) for c in (normal, c1, c2, BASE["red"])]
        item = next(
            i
            for i in _summarize(records, stats)["delta_e"]["mild_comparison"]
            if i["group"] == "red_mild"
        )
        lab = {c: rgb8_to_lab(np.array(c, dtype=np.uint8)) for c in (normal, c1, c2, BASE["red"])}
        mild_mean = (lab[c1] + lab[c2]) / 2
        assert item["delta_e_to_normal"] == pytest.approx(
            delta_e_cie76(mild_mean, lab[normal]), abs=1e-3
        )
        assert item["delta_e_to_strong"] == pytest.approx(
            delta_e_cie76(mild_mean, lab[BASE["red"]]), abs=1e-3
        )

    def test_matrix_is_symmetric_with_zero_diagonal(self) -> None:
        records, stats, _ = _dataset(per_group=2)
        matrix = _summarize(records, stats)["delta_e"]["matrix"]
        for g1 in ALL_DIR_NAMES:
            assert matrix[g1][g1] == pytest.approx(0.0)
            for g2 in ALL_DIR_NAMES:
                assert matrix[g1][g2] == pytest.approx(matrix[g2][g1])


# ---------------------------------------------------------------------------
# 件数・条件・分割の確認
# ---------------------------------------------------------------------------


class TestCounts:
    def test_counts_by_group_class_severity_and_condition(self) -> None:
        records, stats, _ = _dataset(per_group=6)
        # 1 患者 2 枚（strong と mild）の患者を足す
        extra = [
            ManifestRecord("red_strong/x_0.png", f"{SECRET}mix", "red", "strong"),
            ManifestRecord("red_mild/x_1.png", f"{SECRET}mix", "red", "mild"),
        ]
        records = [*records, *extra]
        stats = [*stats, _stats(_uniform((200, 50, 50))), _stats(_uniform((210, 90, 90)))]
        counts = _summarize(records, stats)["counts"]

        assert counts["total"] == {"images": 56, "patients": 55}
        assert counts["groups"]["red_strong"]["images"] == 7
        assert counts["groups"]["red_mild"]["images"] == 7
        assert counts["groups"]["pale_mild"] == {
            "label": "pale",
            "severity": "mild",
            "images": 6,
            "patients": 6,
        }
        assert set(counts["groups"]) == set(ALL_DIR_NAMES)
        assert counts["classes"]["red"] == {"images": 14, "patients": 13}
        assert counts["classes"]["light_red"] == {"images": 6, "patients": 6}
        assert counts["severities"]["mild"]["images"] == 25
        assert counts["severities"]["normal"]["images"] == 6

        with_mild = counts["conditions"]["with_mild"]
        without_mild = counts["conditions"]["without_mild"]
        assert with_mild["images"] == 56
        assert without_mild["images"] == 56 - 25
        # mix 患者は strong の画像が残るため患者数に入る
        assert without_mild["patients"] == 4 * 6 + 6 + 1
        # 除外後も 5 クラスすべてが残る
        assert set(without_mild["classes"]) == set(CLASS_SLUGS)
        assert all(n > 0 for n in without_mild["classes"].values())
        assert without_mild["classes"]["red"] == 7

    def test_split_counts_follow_default_settings(self) -> None:
        records, stats, _ = _dataset(per_group=10)
        split = _summarize(records, stats)["split"]
        assert (split["seed"], split["n_folds"]) == (42, 5)
        single = split["single"]
        # 各グループの件数を分割で足すと全体の件数に一致し，各分割に 9 グループが入る
        for group in ALL_DIR_NAMES:
            assert sum(single["groups"][part][group] for part in ("train", "val", "test")) == 10
            for part in ("train", "val", "test"):
                assert single["groups"][part][group] > 0, (part, group)
        # val / test はグループごとに 10 × 0.15 = 1.5 → 2 人
        for part in ("val", "test"):
            assert all(n == 2 for n in single["groups"][part].values())
        assert sum(single["patients"].values()) == len(records)
        assert len(split["kfold"]) == 5

    def test_split_conditions_are_subsets(self) -> None:
        # 「ややあり除外」の各分割の件数 = 「ややあり込み」の件数 − その分割の mild の件数
        records, stats, _ = _dataset(per_group=10)
        split = _summarize(records, stats)["split"]
        cases = [(split["single"], ("train", "val", "test"))]
        cases += [(fold, ("train", "held_out")) for fold in split["kfold"]]
        for summary, parts in cases:
            for part in parts:
                groups = summary["groups"][part]
                mild = sum(n for g, n in groups.items() if parse_dir_name(g)[1] == "mild")
                with_mild = summary["conditions"]["with_mild"][part]["images"]
                without_mild = summary["conditions"]["without_mild"][part]["images"]
                assert with_mild == sum(groups.values())
                assert without_mild == with_mild - mild

    def test_split_error_does_not_stop_summary(self) -> None:
        # 分割できない構成（グループの人数が少ない）でも集計は止めない
        records, stats, _ = _dataset(per_group=2)
        summary = _summarize(records, stats)
        assert "error" in summary["split"]["kfold"]
        assert SECRET not in json.dumps(summary, ensure_ascii=False)

    def test_configured_severities_are_recorded(self) -> None:
        records, stats, _ = _dataset(per_group=6)
        summary = summarize_dataset(
            records, stats, **SPLIT_KW, configured_severities=("strong", "normal")
        )
        assert summary["configured_severities"] == ["strong", "normal"]

    def test_length_mismatch_is_error(self) -> None:
        records, stats, _ = _dataset(per_group=2)
        with pytest.raises(ValueError):
            _summarize(records, stats[:-1])
